Compare commits
69
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
339dd71f63 | ||
|
|
4aeb26abe9 | ||
|
|
6bd1f31f3a | ||
|
|
32e26ccdd2 | ||
|
|
fac0431a03 | ||
|
|
ed9d0b48c9 | ||
|
|
c6c6866235 | ||
|
|
66c8e9e0c3 | ||
|
|
f39b5dd512 | ||
|
|
b8a27758b6 | ||
|
|
c975e7418b | ||
|
|
4c609cc7ea | ||
|
|
8adaa4d6ea | ||
|
|
e0f9ab7411 | ||
|
|
25eb6481f7 | ||
|
|
72afb31049 | ||
|
|
05a62d18e6 | ||
|
|
111ee15289 | ||
|
|
cb15d11df4 | ||
|
|
07bd42927b | ||
|
|
e49bcf6ba6 | ||
|
|
4cf6cba77c | ||
|
|
9fc7de113a | ||
|
|
69c1dd52cf | ||
|
|
b933f95302 | ||
|
|
384cd550d5 | ||
|
|
5c3883beca | ||
|
|
144e62a35e | ||
|
|
8102556862 | ||
|
|
711c6ae7b0 | ||
|
|
580df29849 | ||
|
|
186f2d0804 | ||
|
|
e4e64b92c1 | ||
|
|
aa43da2857 | ||
|
|
bee3dc9848 | ||
|
|
d95133e257 | ||
|
|
2bf6ff570c | ||
|
|
e58156fbeb | ||
|
|
ddb3ad1a72 | ||
|
|
f6a01500d7 | ||
|
|
ec7aec310a | ||
|
|
0f91ed0d8f | ||
|
|
1e9cc50a73 | ||
|
|
10d2cf0101 | ||
|
|
44949996bc | ||
|
|
baad49b976 | ||
|
|
ceca2017cb | ||
|
|
83e726efe5 | ||
|
|
7d6d8759a5 | ||
|
|
5d89ae3891 | ||
|
|
880ac6c53e | ||
|
|
2d19eef9c2 | ||
|
|
a1d60984e3 | ||
|
|
d5f41e015f | ||
|
|
e1ba77c337 | ||
|
|
a989c5c53c | ||
|
|
a82930027d | ||
|
|
78493b9248 | ||
|
|
5d3fb68c2f | ||
|
|
dc060985a3 | ||
|
|
1d8a854a12 | ||
|
|
d07711f665 | ||
|
|
cf810f9cad | ||
|
|
e142138c1b | ||
|
|
c031fb9f4a | ||
|
|
70a4719bab | ||
|
|
3bdb8b1d90 | ||
|
|
f3fb714126 | ||
|
|
3ed1dd8832 |
No files matched your search
@@ -10,6 +10,8 @@ CRAWL_LIMIT=
|
||||
REQUEST_TIMEOUT=30
|
||||
REQUEST_DELAY_SECONDS=1
|
||||
PARSER_USE_PLAYWRIGHT=false
|
||||
DISMISSAL_CONFIRMATION_RUNS=3
|
||||
MAX_AUTO_DISMISSALS_PER_RUN=25
|
||||
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change-me
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
diagnostic_only:
|
||||
description: 'Print key fingerprint; production deployment is temporarily disabled'
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DATABASE_URL: sqlite:///./ci.db
|
||||
PYTHONUNBUFFERED: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
- run: python -m pip install --upgrade pip
|
||||
- run: python -m pip install -r requirements.txt
|
||||
- run: python -m pytest -q
|
||||
- run: node --check app/static/admin.js
|
||||
- run: sh -n infra/postgres-backup.sh
|
||||
- run: sh -n infra/install-postgres-backup-cron.sh
|
||||
|
||||
deploy:
|
||||
# Production deployment is disabled until separate Workers deployment access is installed.
|
||||
name: Fingerprint diagnostics (production deployment temporarily disabled)
|
||||
if: github.event_name == 'workflow_dispatch' && github.event.inputs.diagnostic_only == 'true'
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: production-deploy
|
||||
cancel-in-progress: false
|
||||
env:
|
||||
PROD_PATH: /srv/miem_workers
|
||||
RELEASE_NAME: miem-workers-release-${{ github.run_id }}.tar
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Prepare release
|
||||
id: prepare
|
||||
shell: bash
|
||||
run: |
|
||||
set +x
|
||||
set -euo pipefail
|
||||
backend_version="$(sed -n 's/^BACKEND_VERSION = "\(.*\)"$/\1/p' app/version.py)"
|
||||
case "$backend_version" in
|
||||
''|*[!0-9.]*) exit 1 ;;
|
||||
esac
|
||||
echo "backend_version=$backend_version" >> "$GITHUB_OUTPUT"
|
||||
tar --exclude=.git --exclude=.env -cf "$RUNNER_TEMP/$RELEASE_NAME" .
|
||||
printf '%s\n' "$PROD_SSH_KEY" > "$RUNNER_TEMP/deploy_key"
|
||||
printf '%s\n' "$PROD_KNOWN_HOSTS" > "$RUNNER_TEMP/known_hosts"
|
||||
chmod 600 "$RUNNER_TEMP/deploy_key" "$RUNNER_TEMP/known_hosts"
|
||||
env:
|
||||
PROD_SSH_KEY: ${{ secrets.PROD_SSH_KEY }}
|
||||
PROD_KNOWN_HOSTS: ${{ secrets.PROD_KNOWN_HOSTS }}
|
||||
- name: Deployment key fingerprint
|
||||
shell: bash
|
||||
run: |
|
||||
set +x
|
||||
set -euo pipefail
|
||||
trap 'rm -f "$RUNNER_TEMP/deploy_key" "$RUNNER_TEMP/known_hosts"' EXIT
|
||||
ssh-keygen -y -P '' -f "$RUNNER_TEMP/deploy_key" | ssh-keygen -lf - -E sha256 | awk '{print $2}'
|
||||
- name: Upload release
|
||||
if: ${{ false }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scp -i "$RUNNER_TEMP/deploy_key" \
|
||||
-o BatchMode=yes \
|
||||
-o UserKnownHostsFile="$RUNNER_TEMP/known_hosts" \
|
||||
"$RUNNER_TEMP/$RELEASE_NAME" \
|
||||
"$PROD_USER@$PROD_HOST:$RELEASE_NAME"
|
||||
env:
|
||||
PROD_HOST: ${{ secrets.PROD_HOST }}
|
||||
PROD_USER: ${{ secrets.PROD_USER }}
|
||||
- name: Deploy and verify
|
||||
if: ${{ false }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ssh -i "$RUNNER_TEMP/deploy_key" \
|
||||
-o BatchMode=yes \
|
||||
-o UserKnownHostsFile="$RUNNER_TEMP/known_hosts" \
|
||||
"$PROD_USER@$PROD_HOST" \
|
||||
"PROD_PATH='$PROD_PATH' RELEASE_NAME='$RELEASE_NAME' EXPECTED_BACKEND_VERSION='$EXPECTED_BACKEND_VERSION' bash -s" <<'REMOTE'
|
||||
set -euo pipefail
|
||||
cd "$PROD_PATH"
|
||||
release="$HOME/$RELEASE_NAME"
|
||||
test -f "$release"
|
||||
archive_version="$(tar -xOf "$release" ./app/version.py | sed -n 's/^BACKEND_VERSION = "\(.*\)"$/\1/p')"
|
||||
test "$archive_version" = "$EXPECTED_BACKEND_VERSION"
|
||||
docker run --rm \
|
||||
-v "$release:/src/release.tar:ro" \
|
||||
-v "$PROD_PATH":/dst \
|
||||
alpine:3.20 sh -c 'tar -xf /src/release.tar -C /dst'
|
||||
sh /srv/miem_workers/infra/install-postgres-backup-cron.sh
|
||||
docker compose up -d --build
|
||||
runtime_version="$(docker compose exec -T api python -c 'from app.version import BACKEND_VERSION; print(BACKEND_VERSION)')"
|
||||
test "$runtime_version" = "$EXPECTED_BACKEND_VERSION"
|
||||
health="$(curl --fail --silent --show-error http://127.0.0.1:8000/api/health)"
|
||||
case "$health" in
|
||||
*"\"status\":\"ok\""*"\"backend_version\":\"$EXPECTED_BACKEND_VERSION\""*) ;;
|
||||
*) echo "Unexpected health response: $health" >&2; exit 1 ;;
|
||||
esac
|
||||
docker compose ps
|
||||
rm -f "$release"
|
||||
REMOTE
|
||||
env:
|
||||
PROD_HOST: ${{ secrets.PROD_HOST }}
|
||||
PROD_USER: ${{ secrets.PROD_USER }}
|
||||
EXPECTED_BACKEND_VERSION: ${{ steps.prepare.outputs.backend_version }}
|
||||
@@ -9,3 +9,5 @@ pytest-cache-files-*/
|
||||
htmlcov/
|
||||
postgres_data/
|
||||
MCP_DESCRIPTION.md
|
||||
|
||||
.impeccable/review/
|
||||
@@ -1,5 +1,75 @@
|
||||
# Changelog
|
||||
|
||||
## 0.8.15
|
||||
|
||||
- Исправлен production deploy: cron installer запускается через `sh` и не требует executable-бита.
|
||||
|
||||
## 0.8.14
|
||||
|
||||
- Production CD больше не создаёт backup при каждом deploy; еженедельная задача
|
||||
сохраняет один полный PostgreSQL backup с атомарной заменой; ошибки и
|
||||
пропущенные из-за lock запуски пишутся в system log.
|
||||
|
||||
## 0.8.12
|
||||
|
||||
- Передача версии в production deploy ограничена безопасным форматом.
|
||||
|
||||
## 0.8.11
|
||||
|
||||
- CSS-ассеты админки получают версию в URL и обновляются после релиза без ручной очистки кэша браузера.
|
||||
|
||||
## 0.8.10
|
||||
|
||||
- Production deploy проверяет совпадение версии release-архива и запущенного API.
|
||||
|
||||
## 0.8.9
|
||||
|
||||
- Контейнеры API, worker и PostgreSQL автоматически перезапускаются после остановки или перезагрузки хоста.
|
||||
|
||||
## 0.8.8
|
||||
|
||||
- Исправлено вертикальное выравнивание кнопки и ссылки в действиях фильтров справочника.
|
||||
|
||||
## 0.8.7
|
||||
|
||||
- Production CD оставляет только последний PostgreSQL backup после успешного деплоя.
|
||||
|
||||
## 0.8.6
|
||||
|
||||
- Исправлена загрузка release-архива production CD в домашний каталог deploy-пользователя.
|
||||
|
||||
## 0.8.5
|
||||
|
||||
- Исправлена адаптивная компоновка фильтров справочника и скрытие skip-link до клавиатурного фокуса.
|
||||
|
||||
## 0.8.4
|
||||
|
||||
- Подключены к конфигурации ограничения массового автоматического увольнения, используемые плановым обходом.
|
||||
|
||||
## 0.8.3
|
||||
|
||||
- Удалены устаревшие пояснение о недоступном endpoint и проверка его отсутствия.
|
||||
|
||||
## 0.8.2
|
||||
|
||||
- Добавлен production CD workflow с backup PostgreSQL и проверкой healthcheck после обновления.
|
||||
|
||||
## 0.8.1
|
||||
|
||||
- Улучшены доступность, фильтры, состояния и адаптивность административного каталога.
|
||||
|
||||
## 0.7.8
|
||||
|
||||
- В колонке учёной степени отображаются все найденные степени без лишнего текста.
|
||||
|
||||
## 0.7.7
|
||||
|
||||
- Удалена неиспользуемая интеграция обмена данными и связанная документация и тесты.
|
||||
|
||||
## 0.7.6
|
||||
|
||||
- Возвращён еженедельный автоматический запуск обхода сотрудников.
|
||||
|
||||
## 0.7.5
|
||||
|
||||
- Production Compose запускает только API и PostgreSQL.
|
||||
|
||||
@@ -1,671 +0,0 @@
|
||||
# MCP: описание работы, структуры и тулзов
|
||||
|
||||
Документ описывает MCP endpoint сервиса `miem-employees` по текущей реализации в `app/mcp.py`.
|
||||
|
||||
## Где находится MCP
|
||||
|
||||
- FastAPI router: `app.mcp.router`
|
||||
- Подключение к приложению: `app/main.py`
|
||||
- HTTP endpoint: `POST /mcp`
|
||||
- Локально при обычном запуске API: `http://localhost:8000/mcp`
|
||||
- В Docker Compose endpoint обслуживает `api`: `http://localhost:8000/mcp`
|
||||
- Авторизация на уровне приложения: отсутствует. Заголовок `Authorization` не проверяется и не влияет на ответ.
|
||||
|
||||
Если доступ к MCP нужно ограничить, это должно делаться внешним контуром: bind на localhost, VPN, firewall, reverse proxy или отдельная сетевая политика.
|
||||
|
||||
## Протокол
|
||||
|
||||
Endpoint принимает JSON-RPC 2.0 over HTTP.
|
||||
|
||||
Общий формат запроса:
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/list",
|
||||
"params": {}
|
||||
}
|
||||
```
|
||||
|
||||
Общий формат успешного ответа:
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": {}
|
||||
}
|
||||
```
|
||||
|
||||
Общий формат ошибки:
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"error": {
|
||||
"code": -32601,
|
||||
"message": "Method not found"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Поддерживаемая версия MCP-протокола:
|
||||
|
||||
```text
|
||||
2024-11-05
|
||||
```
|
||||
|
||||
Имя сервиса:
|
||||
|
||||
```text
|
||||
miem-employees
|
||||
```
|
||||
|
||||
Версия сервера берется из `app.version.BACKEND_VERSION`.
|
||||
|
||||
## Поддерживаемые JSON-RPC методы
|
||||
|
||||
### initialize
|
||||
|
||||
Возвращает метаданные MCP-сервера и capabilities.
|
||||
|
||||
Запрос:
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {}
|
||||
}
|
||||
```
|
||||
|
||||
Ответ:
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"serverInfo": {
|
||||
"name": "miem-employees",
|
||||
"version": "0.7.0"
|
||||
},
|
||||
"capabilities": {
|
||||
"tools": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### tools/list
|
||||
|
||||
Возвращает список доступных tools с JSON Schema для аргументов.
|
||||
|
||||
Запрос:
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/list",
|
||||
"params": {}
|
||||
}
|
||||
```
|
||||
|
||||
Ответ содержит массив `result.tools`.
|
||||
|
||||
### tools/call
|
||||
|
||||
Вызывает один tool по имени.
|
||||
|
||||
Запрос:
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "search_employees",
|
||||
"arguments": {
|
||||
"query": "Сергеев",
|
||||
"limit": 20
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Ответ tool всегда заворачивается в MCP content-массив:
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "{\"items\":[]}"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Поле `text` содержит сериализованный JSON с `ensure_ascii=false`. Клиент должен распарсить это поле как JSON, если ему нужна структурированная нагрузка.
|
||||
|
||||
## Ошибки
|
||||
|
||||
- Неизвестный JSON-RPC метод: `code = -32601`, `message = "Method not found"`.
|
||||
- Исключения при обработке tool: `code = -32000`, `message` содержит текст исключения.
|
||||
- Если сущность не найдена внутри отдельных tools, HTTP и JSON-RPC ответ остаются успешными, а полезная нагрузка содержит `{"error": "not_found"}`.
|
||||
|
||||
## Источники данных
|
||||
|
||||
MCP читает данные из основной базы через SQLAlchemy session из `app.db.get_db`.
|
||||
|
||||
Основные таблицы и модели:
|
||||
|
||||
- `employees`: текущая карточка сотрудника, статус, профиль, `current_data`, checksum.
|
||||
- `employee_publications`: нормализованные публикации сотрудников с авторами, DOI, аннотацией, описанием, citation text и raw JSON из HSE Publications.
|
||||
- `employee_news_links`: нормализованные ссылки на новости из блока профиля «В новостях» с заголовком, URL, кратким описанием, датой, годом публикации и raw JSON карточки.
|
||||
- `crawl_runs`: история запусков парсинга.
|
||||
- `crawl_run_employee_changes`: детальные изменения сотрудников в рамках запуска.
|
||||
- `crawl_errors`: ошибки парсинга в рамках запуска.
|
||||
- `dataset_versions`: версии полного набора сотрудников.
|
||||
- `dataset_version_items`: состав конкретной версии набора сотрудников.
|
||||
|
||||
## Общая структура employee payload
|
||||
|
||||
Краткая карточка сотрудника:
|
||||
|
||||
```json
|
||||
{
|
||||
"profile_key": "staff:avsergeev",
|
||||
"profile_id": "avsergeev",
|
||||
"full_name": "Сергеев Алексей Викторович",
|
||||
"status": "active",
|
||||
"canonical_url": "https://www.hse.ru/staff/avsergeev",
|
||||
"last_seen_at": "2026-05-14T10:00:00+00:00",
|
||||
"dismissed_at": null
|
||||
}
|
||||
```
|
||||
|
||||
В sync payload дополнительно отдается `checksum`.
|
||||
|
||||
Полная карточка дополнительно содержит:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"contacts": {},
|
||||
"sections": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`data` соответствует распарсенному JSON профиля сотрудника. Внутри `sections` могут быть секции с публикациями, курсами, ВКР, новостями, таблицами, ссылками и произвольными текстовыми блоками.
|
||||
|
||||
Пример секции новостей внутри `data.sections`:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "В новостях",
|
||||
"slug": "v_novostyah",
|
||||
"type": "news",
|
||||
"news_count": 1,
|
||||
"news_links": [
|
||||
{
|
||||
"title": "Название новости",
|
||||
"url": "https://www.hse.ru/news/edu/1153850518.html",
|
||||
"summary": "Краткое описание новости.",
|
||||
"published_at": "2026-04-28T00:00:00+00:00",
|
||||
"published_year": 2026
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Для новостей отдельного MCP tool сейчас нет: они доступны через `get_employee(...).data.sections` или через полную синхронизацию `sync_employees(include_data=true)`.
|
||||
|
||||
## Tools
|
||||
|
||||
### get_service_info
|
||||
|
||||
Назначение: вернуть метаданные сервиса, список tools и текущую версию набора сотрудников.
|
||||
|
||||
Аргументы: отсутствуют.
|
||||
|
||||
Возвращает:
|
||||
|
||||
```json
|
||||
{
|
||||
"service_name": "miem-employees",
|
||||
"backend_version": "0.7.0",
|
||||
"protocolVersion": "2024-11-05",
|
||||
"tools": [],
|
||||
"dataset": {
|
||||
"hash": "sha256",
|
||||
"previous_hash": "sha256 или null",
|
||||
"created_at": "2026-05-14T10:00:00+00:00",
|
||||
"crawl_run_id": 123,
|
||||
"employee_count": 100,
|
||||
"active_count": 95,
|
||||
"dismissed_count": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Особенность: перед ответом сервис создает актуальную `dataset_version`, если текущий набор сотрудников еще не имеет версии.
|
||||
|
||||
### sync_employees
|
||||
|
||||
Назначение: синхронизировать клиентский кэш сотрудников по hash набора данных.
|
||||
|
||||
Аргументы:
|
||||
|
||||
```json
|
||||
{
|
||||
"client_hash": "sha256 или null",
|
||||
"include_data": true
|
||||
}
|
||||
```
|
||||
|
||||
- `client_hash`: hash версии, которая уже есть у клиента. Если не передан, отдается полный snapshot.
|
||||
- `include_data`: управляет включением полного `data` в карточки сотрудников. По умолчанию `true`.
|
||||
|
||||
Полный ответ без `client_hash`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "full",
|
||||
"from_hash": null,
|
||||
"to_hash": "current-sha256",
|
||||
"dataset": {},
|
||||
"items": []
|
||||
}
|
||||
```
|
||||
|
||||
Если клиентский hash совпадает с текущим:
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "delta",
|
||||
"from_hash": "current-sha256",
|
||||
"to_hash": "current-sha256",
|
||||
"dataset": {},
|
||||
"changes": {
|
||||
"added": [],
|
||||
"updated": [],
|
||||
"dismissed": [],
|
||||
"removed": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Если `client_hash` неизвестен серверу:
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "full",
|
||||
"from_hash": "missing",
|
||||
"to_hash": "current-sha256",
|
||||
"dataset": {},
|
||||
"items": [],
|
||||
"reason": "unknown_client_hash"
|
||||
}
|
||||
```
|
||||
|
||||
Если `client_hash` найден и отличается от текущего:
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "delta",
|
||||
"from_hash": "old-sha256",
|
||||
"to_hash": "current-sha256",
|
||||
"dataset": {},
|
||||
"changes": {
|
||||
"added": [],
|
||||
"updated": [],
|
||||
"dismissed": [],
|
||||
"removed": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Логика delta:
|
||||
|
||||
- `added`: сотрудник появился в новой версии.
|
||||
- `updated`: изменился checksum или статус, и сотрудник активен.
|
||||
- `dismissed`: сотрудник есть в новой версии, но получил статус `dismissed`.
|
||||
- `removed`: `profile_key` был в старой версии, но отсутствует в новой.
|
||||
|
||||
Hash набора считается по отсортированному списку `{profile_key, status, checksum}`.
|
||||
|
||||
### search_employees
|
||||
|
||||
Назначение: найти сотрудников по ФИО или canonical URL.
|
||||
|
||||
Аргументы:
|
||||
|
||||
```json
|
||||
{
|
||||
"query": "Сергеев",
|
||||
"status": "active",
|
||||
"limit": 20
|
||||
}
|
||||
```
|
||||
|
||||
- `query`: обязательный по schema, но в коде пустая строка означает поиск без текстового фильтра.
|
||||
- `status`: опционально, только `active` или `dismissed`.
|
||||
- `limit`: максимум 100, по умолчанию 20.
|
||||
|
||||
Возвращает массив кратких employee payload без `data`:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"profile_key": "staff:avsergeev",
|
||||
"profile_id": "avsergeev",
|
||||
"full_name": "Сергеев Алексей Викторович",
|
||||
"status": "active",
|
||||
"canonical_url": "https://www.hse.ru/staff/avsergeev",
|
||||
"last_seen_at": "2026-05-14T10:00:00+00:00",
|
||||
"dismissed_at": null
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### get_employee
|
||||
|
||||
Назначение: получить одну карточку сотрудника.
|
||||
|
||||
Аргументы:
|
||||
|
||||
```json
|
||||
{
|
||||
"profile_id_or_url": "avsergeev"
|
||||
}
|
||||
```
|
||||
|
||||
Поиск выполняется по:
|
||||
|
||||
- `profile_key`
|
||||
- `profile_id`
|
||||
- точному `canonical_url`
|
||||
- частичному совпадению `canonical_url`
|
||||
|
||||
Возвращает полный employee payload с `data`.
|
||||
|
||||
Если сотрудник не найден:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "not_found"
|
||||
}
|
||||
```
|
||||
|
||||
### list_employee_publications
|
||||
|
||||
Назначение: вернуть публикации сотрудника. Если есть нормализованные строки в `employee_publications`, tool возвращает детальные публикационные данные: авторов, DOI, аннотацию, описание, citation text, год, тип, язык, статус и ссылки. Если детальная таблица еще не заполнена, tool использует старый fallback из `employees.current_data.sections[].publications`.
|
||||
|
||||
Аргументы:
|
||||
|
||||
```json
|
||||
{
|
||||
"profile_id_or_url": "avsergeev"
|
||||
}
|
||||
```
|
||||
|
||||
Поиск сотрудника выполняется так же, как в `get_employee`: по `profile_key`, `profile_id`, точному или частичному `canonical_url`.
|
||||
|
||||
Порядок источников:
|
||||
|
||||
- сначала `employee_publications`, отсортированные по году, названию и внутреннему id;
|
||||
- если записей нет, секции `current_data.sections` с `type = "publications"` и массивами `publications`.
|
||||
|
||||
Ответ:
|
||||
|
||||
```json
|
||||
{
|
||||
"employee": {
|
||||
"profile_key": "org_person:803294906",
|
||||
"profile_id": "803294906",
|
||||
"full_name": "Борисов Сергей Петрович",
|
||||
"status": "active",
|
||||
"canonical_url": "https://www.hse.ru/org/persons/803294906",
|
||||
"last_seen_at": "2026-05-14T10:00:00+00:00",
|
||||
"dismissed_at": null
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"id": "888959076",
|
||||
"publication_id": "888959076",
|
||||
"title": "Название публикации",
|
||||
"text": "Краткое описание или citation",
|
||||
"url": "https://publications.hse.ru/view/888959076",
|
||||
"year": 2023,
|
||||
"type": "ARTICLE",
|
||||
"publication_type": "ARTICLE",
|
||||
"language": "ru",
|
||||
"status": 1,
|
||||
"doi_url": "https://doi.org/10.53921/18195822_2023_23_4_624",
|
||||
"other_url": "https://example.test",
|
||||
"document_url": "https://example.test/file.pdf",
|
||||
"citation_text": "Авторы. Название публикации // Журнал. 2023.",
|
||||
"annotation": {
|
||||
"ru": "Аннотация",
|
||||
"en": "Abstract"
|
||||
},
|
||||
"description": {
|
||||
"main": "Авторы. Название публикации // Журнал. 2023."
|
||||
},
|
||||
"authors": [
|
||||
{
|
||||
"id": "803294906",
|
||||
"href": "https://www.hse.ru/org/persons/803294906",
|
||||
"title_ru": "Борисов С. П.",
|
||||
"title_en": "",
|
||||
"reverse_title_ru": "С. П. Борисов",
|
||||
"reverse_title_en": "",
|
||||
"alt_name": "S. P. Borisov",
|
||||
"other_name": null,
|
||||
"is_current_employee": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
В fallback-режиме из `current_data` старые элементы могут содержать только базовые поля `title`, `text`, `url` и `id`.
|
||||
|
||||
Если сотрудник не найден:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": []
|
||||
}
|
||||
```
|
||||
|
||||
Если сотрудник найден, но публикаций нет:
|
||||
|
||||
```json
|
||||
{
|
||||
"employee": {},
|
||||
"items": []
|
||||
}
|
||||
```
|
||||
|
||||
### list_employee_courses
|
||||
|
||||
Назначение: вернуть курсы преподавания сотрудника из распарсенных секций профиля.
|
||||
|
||||
Аргументы:
|
||||
|
||||
```json
|
||||
{
|
||||
"profile_id_or_url": "avsergeev"
|
||||
}
|
||||
```
|
||||
|
||||
Сервис ищет секции `current_data.sections` с `type = "courses_by_year"` и объединяет массивы `courses`.
|
||||
|
||||
Ответ:
|
||||
|
||||
```json
|
||||
{
|
||||
"employee": {},
|
||||
"items": [
|
||||
{
|
||||
"title": "Название курса",
|
||||
"url": "https://..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Если сотрудник или данные профиля отсутствуют:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": []
|
||||
}
|
||||
```
|
||||
|
||||
### get_crawl_status
|
||||
|
||||
Назначение: вернуть последний запуск парсинга.
|
||||
|
||||
Аргументы: отсутствуют.
|
||||
|
||||
Ответ:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 123,
|
||||
"status": "completed",
|
||||
"source_url": "https://miem.hse.ru/persons",
|
||||
"started_at": "2026-05-14T10:00:00+00:00",
|
||||
"finished_at": "2026-05-14T10:10:00+00:00",
|
||||
"found_count": 100,
|
||||
"parsed_count": 98,
|
||||
"error_count": 2,
|
||||
"dismissed_count": 1
|
||||
}
|
||||
```
|
||||
|
||||
Если запусков еще не было:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "never_run"
|
||||
}
|
||||
```
|
||||
|
||||
### get_crawl_run_details
|
||||
|
||||
Назначение: вернуть детальную информацию по конкретному запуску парсинга: summary, изменения сотрудников и ошибки.
|
||||
|
||||
Аргументы:
|
||||
|
||||
```json
|
||||
{
|
||||
"run_id": 123
|
||||
}
|
||||
```
|
||||
|
||||
Ответ:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 123,
|
||||
"source_url": "https://miem.hse.ru/persons",
|
||||
"status": "completed",
|
||||
"status_display": "Завершен",
|
||||
"started_at": "2026-05-14T10:00:00+00:00",
|
||||
"finished_at": "2026-05-14T10:10:00+00:00",
|
||||
"started_display": "14.05.2026 13:00",
|
||||
"finished_display": "14.05.2026 13:10",
|
||||
"found_count": 100,
|
||||
"parsed_count": 98,
|
||||
"new_count": 3,
|
||||
"error_count": 2,
|
||||
"dismissed_count": 1,
|
||||
"processed_count": 100,
|
||||
"progress_percent": 100.0,
|
||||
"message": null,
|
||||
"changes_detail_available": true,
|
||||
"changes": {
|
||||
"new": [],
|
||||
"missing_from_source": [],
|
||||
"dismissed": []
|
||||
},
|
||||
"errors": []
|
||||
}
|
||||
```
|
||||
|
||||
Если запуск не найден:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "not_found"
|
||||
}
|
||||
```
|
||||
|
||||
## Примеры curl
|
||||
|
||||
Список tools:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/mcp \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
|
||||
```
|
||||
|
||||
Поиск сотрудника:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/mcp \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search_employees","arguments":{"query":"Сергеев","limit":5}}}'
|
||||
```
|
||||
|
||||
Полная синхронизация:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/mcp \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"sync_employees","arguments":{"include_data":false}}}'
|
||||
```
|
||||
|
||||
Delta-синхронизация:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/mcp \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"sync_employees","arguments":{"client_hash":"known-sha256","include_data":true}}}'
|
||||
```
|
||||
|
||||
## Как MCP используется клиентом
|
||||
|
||||
1. Клиент вызывает `initialize` и проверяет `protocolVersion`.
|
||||
2. Клиент вызывает `tools/list`, чтобы получить актуальный список tools и input schemas.
|
||||
3. Для поиска и точечных запросов клиент вызывает `tools/call` с `search_employees`, `get_employee`, `list_employee_publications`, `list_employee_courses`, `get_crawl_status` или `get_crawl_run_details`.
|
||||
4. Для локального кэша клиент вызывает `get_service_info` или `sync_employees`.
|
||||
5. Клиент хранит последний `dataset.hash`.
|
||||
6. При следующей синхронизации клиент передает hash как `client_hash`.
|
||||
7. Сервер возвращает пустую delta, delta с изменениями или полный snapshot, если hash неизвестен.
|
||||
|
||||
## Важные особенности реализации
|
||||
|
||||
- MCP endpoint read-only: tools не запускают парсинг и не меняют сотрудников напрямую.
|
||||
- `get_service_info` и `sync_employees` могут создать новую запись `dataset_versions`, если состояние сотрудников изменилось и новой версии еще нет.
|
||||
- Все tool payloads возвращаются как JSON-строка внутри `content[0].text`.
|
||||
- `search_employees` ищет через `ilike` по `full_name` и `canonical_url`.
|
||||
- `get_employee` допускает частичный URL, поэтому строка `133709486` может найти `https://www.hse.ru/org/persons/133709486`.
|
||||
- Временные значения сериализуются через `isoformat()`, display-поля для админских payload формируются в часовом поясе `Europe/Moscow`.
|
||||
@@ -1,10 +1,11 @@
|
||||
# MIEM Employees Server
|
||||
|
||||
Сервис собирает сотрудников МИЭМ с сайта ВШЭ, хранит карточки и историю обновлений в Postgres, показывает минимальную админку и отдает read-only MCP endpoint для ИИ-агентов.
|
||||
Сервис собирает сотрудников МИЭМ с сайта ВШЭ, хранит карточки и историю обновлений в Postgres и показывает минимальную админку.
|
||||
|
||||
## Архитектура
|
||||
|
||||
- `api`: FastAPI, REST API, HTML-админка, MCP endpoint и healthcheck.
|
||||
- `api`: FastAPI, REST API, HTML-админка и healthcheck.
|
||||
- `worker`: weekly scheduler, который запускает парсинг по `CRAWL_CRON`.
|
||||
- `postgres`: основная БД.
|
||||
|
||||
Парсер использует фиксированный источник сотрудников, по умолчанию `https://miem.hse.ru/persons`. Для каждой карточки сохраняются ФИО, должности, год начала работы, контакты, идентификаторы, вкладки профиля, секции, публикации, курсы, ВКР, новости, JSON-снапшот и сжатый HTML-снапшот. Детальные публикации дополнительно нормализуются в отдельную таблицу `employee_publications`, а новости из блока «В новостях» — в `employee_news_links`. Ссылки обходятся только из меню профиля самого сотрудника (`person-menu`), например `#sci`, `#teaching`, `#main`.
|
||||
@@ -20,7 +21,8 @@ cp .env.example .env
|
||||
Основные настройки:
|
||||
|
||||
- `DATABASE_URL`: строка подключения SQLAlchemy.
|
||||
- `SOURCE_URL`: список сотрудников МИЭМ.
|
||||
- `SOURCE_URL`: список сотрудников МИЭМ.
|
||||
- `CRAWL_CRON`: расписание в формате crontab, по умолчанию `0 3 * * 1`.
|
||||
- `CRAWL_LIMIT`: опциональный лимит профилей для тестового запуска.
|
||||
- `ADMIN_USERNAME`, `ADMIN_PASSWORD`: логин и пароль админки.
|
||||
- `SESSION_SECRET`: секрет подписи cookie.
|
||||
@@ -41,9 +43,15 @@ uvicorn app.main:app --reload
|
||||
|
||||
В админке доступны:
|
||||
|
||||
- `Dashboard`: общая статистика, последний добавленный сотрудник, прогресс текущего/последнего парсинга и ручной запуск.
|
||||
- `Directory`: настраиваемая таблица сотрудников с фильтрами, сортировкой, пагинацией и выбором колонок.
|
||||
- `Runs`: история запусков, ошибки и progress bar.
|
||||
- «Обзор»: статистика, последний добавленный сотрудник, прогресс парсинга и ручной запуск.
|
||||
- «Сотрудники»: поиск, фильтры, сортировка, пагинация и выбор колонок. По умолчанию показаны ФИО, статус, должности, дата последнего обнаружения и внешний профиль. В диалоге колонок доступны наборы «Проверка», «Контакты» и «Все поля»; ранее сохранённый выбор сохраняется.
|
||||
- «Запуски»: история обходов, ошибки и доступный индикатор прогресса.
|
||||
|
||||
Все фильтры применяются кнопкой «Применить фильтры» с переходом на первую страницу. «Сбросить» очищает условия; при устаревшем номере страницы каталог возвращает первую. Подписи полей показывают выбранные условия, над таблицей указан диапазон результатов. Пояснения дат и статусов находятся под фильтрами.
|
||||
|
||||
Каталог поддерживает навигацию с клавиатуры и системный диалог колонок с Escape и возвратом фокуса. На узких экранах таблица прокручивается внутри страницы, колонка ФИО закреплена. Используется системный шрифт без внешних загрузок.
|
||||
|
||||
Каталог формируется сервером: отдельная клиентская загрузка списка не нужна, переход показывает браузер. Пустой результат предлагает сбросить фильтры, пустая база — запустить парсинг. При ошибке базы каталог возвращает HTTP 503 и предлагает повторить загрузку с теми же фильтрами. При обновлении прогресса показывается состояние загрузки; при ошибке сохраняются последние значения с предупреждением об их актуальности и кнопкой «Повторить». Автоматические попытки продолжаются каждые 4 секунды, запрос ограничен 15 секундами; параллельные запросы не запускаются.
|
||||
|
||||
## Docker Compose
|
||||
|
||||
@@ -54,7 +62,9 @@ docker compose up -d --build --remove-orphans
|
||||
По умолчанию:
|
||||
|
||||
- API и админка: `http://localhost:8000`
|
||||
- Postgres: `localhost:5432`
|
||||
- PostgreSQL: `postgres:5432` внутри сети Compose; порт на хост не опубликован.
|
||||
|
||||
Compose запускает `api`, `worker` и `postgres`. API привязан к localhost; для внешнего доступа нужен настроенный reverse proxy. REST API данных требует сессию администратора.
|
||||
|
||||
Таблицы создаются приложением при старте. При обновлении существующей базы приложение также добавляет недостающие runtime-колонки, например `crawl_runs.skipped_count`. SQL-миграции для ручного применения лежат в `migrations/`.
|
||||
|
||||
@@ -67,20 +77,26 @@ docker compose up -d --build --remove-orphans
|
||||
- краткий список остается внутри `employees.current_data.sections[].publications` для обратной совместимости;
|
||||
- детальные записи сохраняются в `employee_publications` и связываются с сотрудником через `employee_id`.
|
||||
|
||||
`employee_publications` содержит `publication_id`, название, год, тип публикации, язык, статус, ссылку на карточку HSE Publications, DOI, внешние/document-ссылки, citation text, аннотацию, описание, авторов, raw JSON ответа `searchPubs` и `source_hash` для безопасного повторного upsert. Уникальность поддерживается по `(employee_id, publication_id)` и `(employee_id, source_hash)`, поэтому повторный crawl не должен создавать дубликаты.
|
||||
|
||||
`list_employee_publications` сначала читает `employee_publications`; если детальных строк еще нет, возвращает старые публикации из `current_data`.
|
||||
|
||||
Новости сотрудников также хранятся в двух видах:
|
||||
`employee_publications` содержит `publication_id`, название, год, тип публикации, язык, статус, ссылку на карточку HSE Publications, DOI, внешние/document-ссылки, citation text, аннотацию, описание, авторов, raw JSON ответа `searchPubs` и `source_hash` для безопасного повторного upsert. Уникальность поддерживается по `(employee_id, publication_id)` и `(employee_id, source_hash)`, поэтому повторный crawl не должен создавать дубликаты.
|
||||
|
||||
Новости сотрудников также хранятся в двух видах:
|
||||
|
||||
- краткий список остается внутри `employees.current_data.sections[].news_links`;
|
||||
- нормализованные карточки из вкладки «В новостях» сохраняются в `employee_news_links`.
|
||||
|
||||
`employee_news_links` содержит название новости, ссылку, краткое описание, дату публикации, год публикации, raw JSON карточки и `source_hash`. Уникальность поддерживается по `(employee_id, url)` и `(employee_id, source_hash)`, поэтому повторный crawl не создает дубликаты.
|
||||
`employee_news_links` содержит название новости, ссылку, краткое описание, дату публикации, год публикации, raw JSON карточки и `source_hash`. Уникальность поддерживается по `(employee_id, url)` и `(employee_id, source_hash)`, поэтому повторный crawl не создает дубликаты.
|
||||
|
||||
## Кандидаты в диссертационный совет
|
||||
|
||||
Страница `/admin/council` доступна без авторизации. Она показывает сотрудников с докторской степенью в профиле и хотя бы одной публикацией в CSV или таблице `employee_publications`. Порог количества работ не применяется; сотрудники без степени, с кандидатской степенью или только PhD в список не включаются; уволенные сотрудники отмечены отдельно. Есть поиск по ФИО, сортировка по ФИО и каждому из трёх счётчиков публикаций в обоих направлениях и страницы по 25 сотрудников. Сортировка применяется ко всему списку и сохраняется при поиске и переходе между страницами. Кнопка «Скачать XLSX» выгружает всех найденных сотрудников с выбранными поиском и сортировкой, включая записи на других страницах. Файл содержит листы «Сотрудники», «Публикации» и «Параметры» с источниками, критериями и условиями отбора; экспорт доступен без авторизации по `/admin/council/export.xlsx`. У каждого сотрудника видны степень, число уникальных работ из CSV с Q1/Q2 за скользящие пять лет, число уникальных работ в БД и общий перечень без дублей с источниками, типами и подтверждёнными критериями.
|
||||
|
||||
CSV `app/data/council_articles.csv` сопоставляется по полному ФИО только при единственном совпадении. Публикации объединяются по DOI, затем по нормализованным названию и году, если нет конфликтующих DOI. Совпадение в двух источниках сохраняет оба источника, но учитывается один раз в общем количестве. Импорт CSV в БД не выдаётся за данные сайта. CSV не содержит типа материала, точной даты и подтверждения К1/RSCI: неизвестные сведения обозначены явно; счётчик CSV предварительный. Публичная страница не открывает контакты, служебные данные или операции админки.
|
||||
|
||||
Список **предварительный**: в CSV указаны год и квартиль Scopus/WoS, но нет точной даты, типа материала, журнала, источника квартиля, подтверждения К1 и RSCI. На границах периода год не позволяет проверить точную дату. Статьи с Q1/Q2 учитываются с пометкой источника; отсутствие кандидата не доказывает недостаточную публикационную активность. Для подтверждённого отбора нужны проверка журналов и дат и дополнительные источники публикаций. Выгрузка поставляется вместе с релизом и обновляется отдельным изменением репозитория.
|
||||
|
||||
## Парсинг
|
||||
|
||||
Ручной запуск доступен в админке на `Dashboard` и странице `Runs` или через REST:
|
||||
Worker запускает обход по `CRAWL_CRON`. Ручной запуск также доступен в админке на `Dashboard` и странице `Runs` или через REST:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/crawl-runs --cookie "miem_admin_session=..."
|
||||
@@ -105,41 +121,36 @@ curl -X POST http://localhost:8000/api/crawl-runs --cookie "miem_admin_session=.
|
||||
|
||||
Во время выполнения парсинга `found_count`, `parsed_count`, `skipped_count` и `error_count` обновляются в базе. Админка опрашивает `/api/crawl-runs/latest` и показывает прогресс как `(parsed_count + skipped_count + error_count) / found_count`.
|
||||
|
||||
## MCP
|
||||
|
||||
Endpoint: `POST /mcp`, без авторизации на уровне приложения.
|
||||
|
||||
Поддерживаемые tools:
|
||||
|
||||
- `get_service_info()`
|
||||
- `sync_employees(client_hash?, include_data?)`
|
||||
- `search_employees(query, status?, limit?)`
|
||||
- `get_employee(profile_id_or_url)`
|
||||
- `list_employee_publications(profile_id_or_url)` — публикации сотрудника; при наличии данных из `employee_publications` возвращает авторов, DOI, аннотацию, описание, citation text, год, тип, язык, статус и ссылку HSE Publications.
|
||||
- `list_employee_courses(profile_id_or_url)`
|
||||
- `get_crawl_status()`
|
||||
- `get_crawl_run_details(run_id)`
|
||||
|
||||
`get_service_info` возвращает метаданные сервиса, список tools и текущую версию набора сотрудников. `sync_employees` отдает полный snapshot или delta по `client_hash`; checksum набора строится по сотрудникам, их статусам и текущим checksums. Ответы tools возвращаются как JSON-строка внутри MCP `content[0].text`.
|
||||
|
||||
Новости сотрудника отдельной MCP tool не имеют: они доступны в `get_employee(...).data.sections` и `sync_employees(include_data=true)` как секция `type = "news"` с массивом `news_links`.
|
||||
|
||||
Пример локального запроса списка tools:
|
||||
## Обслуживание
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/mcp \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
|
||||
```
|
||||
|
||||
Если MCP нужно ограничить, делайте это на сетевом уровне: localhost binding, VPN, firewall, reverse proxy или другой внешний контур доступа.
|
||||
|
||||
## Обслуживание
|
||||
|
||||
```bash
|
||||
docker compose logs -f api
|
||||
docker compose logs -f api
|
||||
docker compose logs -f worker
|
||||
docker compose exec postgres pg_dump -U miem miem_workers > backup.sql
|
||||
docker compose down
|
||||
```
|
||||
|
||||
Версия сервиса: `0.7.5`. Админка всегда показывает версии backend и frontend в footer.
|
||||
docker compose down
|
||||
```
|
||||
|
||||
Production deployment временно отключён до установки отдельного deployment-доступа Workers. Workflow `CI` запускает тесты на push в `main` и pull_request в `main`; шаги Upload release и Deploy and verify безусловно отключены для всех событий и значений input. Стандартные имена deployment secrets: `PROD_HOST`, `PROD_USER`, `PROD_SSH_KEY`, `PROD_KNOWN_HOSTS`; `.env` и ключи в репозиторий не добавляются. PostgreSQL backup выполняется на VPS раз в неделю user cron deploy-пользователя через `infra/postgres-backup.sh`: `pg_dumpall` сохраняет весь кластер в `~/backups/postgres/postgres-latest.sql`. Новый dump сначала создаётся во временный файл и заменяет старый только после успешного завершения; предыдущие dump-файлы `miem_workers` удаляются после успешного создания нового. Эта правка не меняет существующий backup cron.
|
||||
|
||||
Ручной запуск с `diagnostic_only=true` (по умолчанию) выводит только SHA256 fingerprint ключа `PROD_SSH_KEY`, без подключения к VPS. Публичный ключ передаётся через pipe, не сохраняется в файл или artifacts; shell tracing отключён. При `diagnostic_only=false` диагностический job пропускается: это значение также не разрешает deployment. Возврат production deployment требует отдельной reviewed правки после установки нового доступа.
|
||||
|
||||
## Проверки
|
||||
|
||||
После установки зависимостей из `requirements.txt` выполните:
|
||||
|
||||
```bash
|
||||
python -m pytest -q
|
||||
node --check app/static/admin.js
|
||||
```
|
||||
|
||||
Тесты с данными используют временную SQLite; отдельные API smoke-тесты запускают приложение с его текущей конфигурацией. Браузерная проверка: `pip install playwright`, `python -m playwright install chromium`, затем `python tests/browser_admin.py`. Она проверяет реальный рендеринг страниц, клавиатуру, диалог, фильтры и восстановление прогресса после ошибки. Скриншоты сохраняются во временную папку; путь выводится в конце.
|
||||
|
||||
Версия сервиса: `0.10.6`. Админка всегда показывает версии 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). Стенд запускает только владелец после оценки ресурсов.
|
||||
+53
-22
@@ -1,7 +1,8 @@
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Form, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
@@ -16,8 +17,10 @@ from app.services.admin_data import (
|
||||
run_payload,
|
||||
stats_payload,
|
||||
)
|
||||
from app.services.crawl_control import get_running_run, run_crawl_if_idle
|
||||
from app.services.crawl_control import get_running_run, run_crawl_if_idle
|
||||
from app.services.crawler import refresh_dismissed_status, refresh_employee
|
||||
from app.services.council import council_candidates
|
||||
from app.services.council_export import council_xlsx
|
||||
from app.version import BACKEND_VERSION, FRONTEND_VERSION
|
||||
|
||||
router = APIRouter(prefix="/admin")
|
||||
@@ -72,8 +75,8 @@ def employees(
|
||||
return RedirectResponse("/admin/directory", status_code=303)
|
||||
|
||||
|
||||
@router.get("/directory", response_class=HTMLResponse)
|
||||
def directory(
|
||||
@router.get("/directory", response_class=HTMLResponse)
|
||||
def directory(
|
||||
request: Request,
|
||||
status: str | None = None,
|
||||
q: str | None = None,
|
||||
@@ -93,19 +96,23 @@ def directory(
|
||||
parsed_started_to = _parse_date(started_to)
|
||||
parsed_has_email = None if has_email in (None, "") else has_email == "true"
|
||||
parsed_has_academic_degree = None if has_academic_degree in (None, "") else has_academic_degree == "true"
|
||||
page = list_employees_page(
|
||||
db,
|
||||
status=status,
|
||||
q=q,
|
||||
started_from=parsed_started_from,
|
||||
started_to=parsed_started_to,
|
||||
has_email=parsed_has_email,
|
||||
has_academic_degree=parsed_has_academic_degree,
|
||||
sort=sort,
|
||||
direction=direction,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
try:
|
||||
page = list_employees_page(
|
||||
db,
|
||||
status=status,
|
||||
q=q,
|
||||
started_from=parsed_started_from,
|
||||
started_to=parsed_started_to,
|
||||
has_email=parsed_has_email,
|
||||
has_academic_degree=parsed_has_academic_degree,
|
||||
sort=sort,
|
||||
direction=direction,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
except SQLAlchemyError:
|
||||
db.rollback()
|
||||
return _render(request, "directory_error.html", {}, status_code=503)
|
||||
return _render(
|
||||
request,
|
||||
"directory.html",
|
||||
@@ -121,13 +128,37 @@ def directory(
|
||||
"sort": sort,
|
||||
"direction": direction,
|
||||
"limit": page["limit"],
|
||||
"offset": offset,
|
||||
"offset": page["offset"],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/employees/{employee_id}", response_class=HTMLResponse)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/council", response_class=HTMLResponse)
|
||||
def council(request: Request, q: str = "", offset: int = 0, sort: str = "csv_count", direction: str = "desc",
|
||||
db: Session = Depends(get_db)):
|
||||
try:
|
||||
result = council_candidates(db, q=q, offset=offset, sort=sort, direction=direction)
|
||||
except (SQLAlchemyError, OSError, ValueError, KeyError):
|
||||
db.rollback()
|
||||
return _render(request, "council.html", {"error": True, "public_page": True}, status_code=503)
|
||||
return _render(request, "council.html", {"error": False, "public_page": True, **result})
|
||||
|
||||
|
||||
@router.get("/council/export.xlsx")
|
||||
def export_council(request: Request, q: str = "", sort: str = "csv_count", direction: str = "desc",
|
||||
db: Session = Depends(get_db)):
|
||||
try:
|
||||
result = council_candidates(db, q=q, sort=sort, direction=direction, paginate=False)
|
||||
content = council_xlsx(result)
|
||||
except (SQLAlchemyError, OSError, ValueError, KeyError):
|
||||
db.rollback()
|
||||
return _render(request, "council.html", {"error": True, "public_page": True, "export_error": True}, status_code=503)
|
||||
return Response(content, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": 'attachment; filename="council.xlsx"'})
|
||||
|
||||
|
||||
@router.get("/employees/{employee_id}", response_class=HTMLResponse)
|
||||
def employee_detail(
|
||||
employee_id: int,
|
||||
request: Request,
|
||||
|
||||
@@ -13,6 +13,8 @@ class Settings(BaseSettings):
|
||||
request_timeout: int = 30
|
||||
request_delay_seconds: float = 1.0
|
||||
parser_use_playwright: bool = False
|
||||
dismissal_confirmation_runs: int = Field(default=3, ge=1)
|
||||
max_auto_dismissals_per_run: int = Field(default=25, ge=1)
|
||||
|
||||
admin_username: str = "admin"
|
||||
admin_password: str = "admin"
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -4,14 +4,12 @@ from fastapi.staticfiles import StaticFiles
|
||||
from app.admin import router as admin_router
|
||||
from app.api import router as api_router
|
||||
from app.db import init_db
|
||||
from app.mcp import router as mcp_router
|
||||
from app.version import BACKEND_VERSION
|
||||
|
||||
app = FastAPI(title="MIEM Employees", version=BACKEND_VERSION)
|
||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||
app.include_router(api_router)
|
||||
app.include_router(admin_router)
|
||||
app.include_router(mcp_router)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
|
||||
-262
@@ -1,262 +0,0 @@
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy import desc, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import get_db
|
||||
from app.models import CrawlRun, Employee, EmployeePublication
|
||||
from app.services.admin_data import run_detail_payload
|
||||
from app.services.dataset_versions import service_info_payload, sync_employees_payload
|
||||
from app.version import BACKEND_VERSION
|
||||
|
||||
router = APIRouter(prefix="/mcp")
|
||||
PROTOCOL_VERSION = "2024-11-05"
|
||||
SERVICE_NAME = "miem-employees"
|
||||
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"name": "get_service_info",
|
||||
"description": "Return service metadata, supported tools, and current dataset version.",
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
},
|
||||
{
|
||||
"name": "sync_employees",
|
||||
"description": "Synchronize employees by dataset hash. Returns a full snapshot or a delta from client_hash.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"client_hash": {"type": "string"},
|
||||
"include_data": {"type": "boolean", "default": True},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "search_employees",
|
||||
"description": "Search MIEM employees by name or profile URL.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string"},
|
||||
"status": {"type": "string", "enum": ["active", "dismissed"]},
|
||||
"limit": {"type": "integer", "default": 20},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "get_employee",
|
||||
"description": "Get one employee by profile id, profile key, or canonical URL.",
|
||||
"inputSchema": {"type": "object", "properties": {"profile_id_or_url": {"type": "string"}}, "required": ["profile_id_or_url"]},
|
||||
},
|
||||
{
|
||||
"name": "list_employee_publications",
|
||||
"description": (
|
||||
"List employee publications with detailed fields when available: authors, DOI URL, annotation, "
|
||||
"description, citation text, year, publication type, language, status, and HSE Publications URL."
|
||||
),
|
||||
"inputSchema": {"type": "object", "properties": {"profile_id_or_url": {"type": "string"}}, "required": ["profile_id_or_url"]},
|
||||
},
|
||||
{
|
||||
"name": "list_employee_courses",
|
||||
"description": "List teaching courses parsed from an employee profile.",
|
||||
"inputSchema": {"type": "object", "properties": {"profile_id_or_url": {"type": "string"}}, "required": ["profile_id_or_url"]},
|
||||
},
|
||||
{
|
||||
"name": "get_crawl_status",
|
||||
"description": "Return the latest crawl run status.",
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
},
|
||||
{
|
||||
"name": "get_crawl_run_details",
|
||||
"description": "Return detailed employee changes and errors for one crawl run.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"run_id": {"type": "integer"}},
|
||||
"required": ["run_id"],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def mcp_http(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
payload = await request.json()
|
||||
method = payload.get("method")
|
||||
request_id = payload.get("id")
|
||||
params = payload.get("params") or {}
|
||||
|
||||
try:
|
||||
if method == "initialize":
|
||||
result = {
|
||||
"protocolVersion": PROTOCOL_VERSION,
|
||||
"serverInfo": {"name": SERVICE_NAME, "version": BACKEND_VERSION},
|
||||
"capabilities": {"tools": {}},
|
||||
}
|
||||
elif method == "tools/list":
|
||||
result = {"tools": TOOLS}
|
||||
elif method == "tools/call":
|
||||
result = _call_tool(db, params.get("name"), params.get("arguments") or {})
|
||||
else:
|
||||
return {"jsonrpc": "2.0", "id": request_id, "error": {"code": -32601, "message": "Method not found"}}
|
||||
return {"jsonrpc": "2.0", "id": request_id, "result": result}
|
||||
except Exception as exc:
|
||||
return {"jsonrpc": "2.0", "id": request_id, "error": {"code": -32000, "message": str(exc)}}
|
||||
|
||||
|
||||
def _call_tool(db: Session, name: str, arguments: dict) -> dict:
|
||||
if name == "get_service_info":
|
||||
return _tool_response(
|
||||
service_info_payload(
|
||||
db,
|
||||
tools=TOOLS,
|
||||
service_name=SERVICE_NAME,
|
||||
backend_version=BACKEND_VERSION,
|
||||
protocol_version=PROTOCOL_VERSION,
|
||||
)
|
||||
)
|
||||
if name == "sync_employees":
|
||||
return _tool_response(
|
||||
sync_employees_payload(
|
||||
db,
|
||||
client_hash=arguments.get("client_hash"),
|
||||
include_data=bool(arguments.get("include_data", True)),
|
||||
)
|
||||
)
|
||||
if name == "search_employees":
|
||||
return _tool_response(_search_employees(db, arguments))
|
||||
if name == "get_employee":
|
||||
employee = _find_employee(db, arguments["profile_id_or_url"])
|
||||
return _tool_response(_employee_payload(employee) if employee else {"error": "not_found"})
|
||||
if name == "list_employee_publications":
|
||||
employee = _find_employee(db, arguments["profile_id_or_url"])
|
||||
return _tool_response(_collect_section_items(employee, "publications"))
|
||||
if name == "list_employee_courses":
|
||||
employee = _find_employee(db, arguments["profile_id_or_url"])
|
||||
return _tool_response(_collect_section_items(employee, "courses_by_year"))
|
||||
if name == "get_crawl_status":
|
||||
run = db.scalar(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(1))
|
||||
return _tool_response(_run_payload(run) if run else {"status": "never_run"})
|
||||
if name == "get_crawl_run_details":
|
||||
run = db.get(CrawlRun, int(arguments["run_id"]))
|
||||
return _tool_response(run_detail_payload(db, run) if run else {"error": "not_found"})
|
||||
raise ValueError(f"Unknown tool: {name}")
|
||||
|
||||
|
||||
def _search_employees(db: Session, arguments: dict) -> list[dict]:
|
||||
query = arguments.get("query", "")
|
||||
limit = min(int(arguments.get("limit") or 20), 100)
|
||||
stmt = select(Employee)
|
||||
if arguments.get("status"):
|
||||
stmt = stmt.where(Employee.status == arguments["status"])
|
||||
if query:
|
||||
pattern = f"%{query}%"
|
||||
stmt = stmt.where(or_(Employee.full_name.ilike(pattern), Employee.canonical_url.ilike(pattern)))
|
||||
employees = db.scalars(stmt.order_by(Employee.full_name).limit(limit)).all()
|
||||
return [_employee_payload(employee, include_data=False) for employee in employees]
|
||||
|
||||
|
||||
def _find_employee(db: Session, value: str) -> Employee | None:
|
||||
pattern = value.strip()
|
||||
stmt = select(Employee).where(
|
||||
or_(
|
||||
Employee.profile_key == pattern,
|
||||
Employee.profile_id == pattern,
|
||||
Employee.canonical_url == pattern,
|
||||
Employee.canonical_url.ilike(f"%{pattern}%"),
|
||||
)
|
||||
)
|
||||
return db.scalar(stmt.limit(1))
|
||||
|
||||
|
||||
def _collect_section_items(employee: Employee | None, section_type: str) -> dict:
|
||||
if not employee:
|
||||
return {"items": []}
|
||||
if section_type == "publications":
|
||||
publications = _stored_publications(employee)
|
||||
if publications:
|
||||
return {"employee": _employee_payload(employee, include_data=False), "items": publications}
|
||||
if not employee.current_data:
|
||||
return {"employee": _employee_payload(employee, include_data=False), "items": []}
|
||||
items = []
|
||||
for section in employee.current_data.get("sections") or []:
|
||||
if section.get("type") != section_type:
|
||||
continue
|
||||
if section_type == "publications":
|
||||
items.extend(section.get("publications") or [])
|
||||
elif section_type == "courses_by_year":
|
||||
items.extend(section.get("courses") or [])
|
||||
return {"employee": _employee_payload(employee, include_data=False), "items": items}
|
||||
|
||||
|
||||
def _stored_publications(employee: Employee) -> list[dict]:
|
||||
return [_publication_payload(publication) for publication in sorted(employee.publications, key=_publication_sort_key)]
|
||||
|
||||
|
||||
def _publication_sort_key(publication: EmployeePublication) -> tuple:
|
||||
return (publication.year or 0, publication.title or "", publication.id)
|
||||
|
||||
|
||||
def _publication_payload(publication: EmployeePublication) -> dict:
|
||||
text = publication.citation_text or publication.title
|
||||
payload = {
|
||||
"id": publication.publication_id,
|
||||
"publication_id": publication.publication_id,
|
||||
"title": publication.title,
|
||||
"text": text,
|
||||
"url": publication.url,
|
||||
}
|
||||
optional = {
|
||||
"year": publication.year,
|
||||
"type": publication.publication_type,
|
||||
"publication_type": publication.publication_type,
|
||||
"language": publication.language,
|
||||
"status": publication.status,
|
||||
"doi_url": publication.doi_url,
|
||||
"other_url": publication.other_url,
|
||||
"document_url": publication.document_url,
|
||||
"citation_text": publication.citation_text,
|
||||
"annotation": publication.annotation,
|
||||
"description": publication.description,
|
||||
"authors": publication.authors,
|
||||
}
|
||||
payload.update({key: value for key, value in optional.items() if value not in (None, [], {})})
|
||||
return payload
|
||||
|
||||
|
||||
def _employee_payload(employee: Employee, include_data: bool = True) -> dict:
|
||||
payload = {
|
||||
"profile_key": employee.profile_key,
|
||||
"profile_id": employee.profile_id,
|
||||
"full_name": employee.full_name,
|
||||
"status": employee.status,
|
||||
"canonical_url": employee.canonical_url,
|
||||
"last_seen_at": employee.last_seen_at.isoformat() if employee.last_seen_at else None,
|
||||
"dismissed_at": employee.dismissed_at.isoformat() if employee.dismissed_at else None,
|
||||
}
|
||||
if include_data:
|
||||
payload["data"] = employee.current_data
|
||||
return payload
|
||||
|
||||
|
||||
def _run_payload(run: CrawlRun) -> dict:
|
||||
return {
|
||||
"id": run.id,
|
||||
"status": run.status,
|
||||
"source_url": run.source_url,
|
||||
"started_at": run.started_at.isoformat() if run.started_at else None,
|
||||
"finished_at": run.finished_at.isoformat() if run.finished_at else None,
|
||||
"found_count": run.found_count,
|
||||
"parsed_count": run.parsed_count,
|
||||
"skipped_count": run.skipped_count,
|
||||
"error_count": run.error_count,
|
||||
"dismissed_count": run.dismissed_count,
|
||||
}
|
||||
|
||||
|
||||
def _tool_response(data: object) -> dict:
|
||||
return {"content": [{"type": "text", "text": json.dumps(data, ensure_ascii=False, default=str)}]}
|
||||
@@ -162,7 +162,6 @@ class CrawlRun(Base):
|
||||
message: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
employee_changes: Mapped[list["CrawlRunEmployeeChange"]] = relationship(back_populates="crawl_run")
|
||||
dataset_versions: Mapped[list["DatasetVersion"]] = relationship(back_populates="crawl_run")
|
||||
|
||||
|
||||
class CrawlRunEmployeeChange(Base):
|
||||
@@ -242,42 +241,3 @@ class ParseResourceCache(Base):
|
||||
body_snapshot: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
|
||||
parser_version: Mapped[str | None] = mapped_column(String(32))
|
||||
fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||
|
||||
|
||||
class DatasetVersion(Base):
|
||||
__tablename__ = "dataset_versions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("hash", name="uq_dataset_versions_hash"),
|
||||
Index("ix_dataset_versions_created_at", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
previous_hash: Mapped[str | None] = mapped_column(String(64))
|
||||
crawl_run_id: Mapped[int | None] = mapped_column(ForeignKey("crawl_runs.id"))
|
||||
employee_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
active_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
dismissed_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||
|
||||
crawl_run: Mapped[CrawlRun | None] = relationship(back_populates="dataset_versions")
|
||||
items: Mapped[list["DatasetVersionItem"]] = relationship(back_populates="dataset_version", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class DatasetVersionItem(Base):
|
||||
__tablename__ = "dataset_version_items"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("dataset_version_id", "profile_key", name="uq_dataset_version_items_version_profile"),
|
||||
Index("ix_dataset_version_items_hash", "dataset_version_id"),
|
||||
Index("ix_dataset_version_items_profile_key", "profile_key"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
dataset_version_id: Mapped[int] = mapped_column(ForeignKey("dataset_versions.id"), nullable=False)
|
||||
profile_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
employee_id: Mapped[int | None] = mapped_column(ForeignKey("employees.id"))
|
||||
status: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
checksum: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
|
||||
dataset_version: Mapped[DatasetVersion] = relationship(back_populates="items")
|
||||
employee: Mapped[Employee | None] = relationship()
|
||||
@@ -2,13 +2,16 @@ import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
_PATTERN = re.compile(r"\b(?:кандидат|доктор)\s+[\w\s-]{0,80}?\s+наук\b|\bph\.?\s*d\.?\b", re.IGNORECASE)
|
||||
_PATTERN = re.compile(r"\b(?:кандидат|доктор)(?:\s+[\w-]+){0,12}\s+наук\b|\bph\.?\s*d\.?(?!\w)", re.IGNORECASE)
|
||||
|
||||
|
||||
def academic_degrees(data: dict[str, Any] | None) -> list[str]:
|
||||
degrees = []
|
||||
seen = set()
|
||||
for section in (data or {}).get("sections") or []:
|
||||
if not isinstance(section, dict) or not re.search(r"уч[её]н.*степен|academic degree", str(section.get("title") or ""), re.IGNORECASE):
|
||||
if not isinstance(section, dict) or not re.search(
|
||||
r"образован|степен|academic degree|education", str(section.get("title") or ""), re.IGNORECASE
|
||||
):
|
||||
continue
|
||||
values = [
|
||||
*(entry.get("text") for entry in section.get("year_entries") or [] if isinstance(entry, dict)),
|
||||
@@ -16,8 +19,15 @@ def academic_degrees(data: dict[str, Any] | None) -> list[str]:
|
||||
*(section.get("items") or []),
|
||||
section.get("raw_text"),
|
||||
]
|
||||
table = section.get("table") or {}
|
||||
for row in table.get("rows") or []:
|
||||
if isinstance(row, dict):
|
||||
values.extend(row.get("cells") or [])
|
||||
for value in values:
|
||||
text = str(value or "").strip()
|
||||
if text and _PATTERN.search(text) and text not in degrees:
|
||||
degrees.append(text)
|
||||
for match in _PATTERN.finditer(text):
|
||||
degree = match.group(0).strip()
|
||||
if degree.casefold() not in seen:
|
||||
seen.add(degree.casefold())
|
||||
degrees.append(degree)
|
||||
return degrees
|
||||
@@ -132,6 +132,8 @@ def list_employees_page(
|
||||
has_academic_degree=has_academic_degree,
|
||||
)
|
||||
total = db.scalar(select(func.count()).select_from(base_stmt.subquery())) or 0
|
||||
if offset >= total:
|
||||
offset = 0
|
||||
sort_column = EMPLOYEE_SORTS.get(sort, Employee.full_name)
|
||||
order = desc(sort_column) if direction == "desc" else sort_column
|
||||
employees = db.scalars(base_stmt.order_by(order).limit(limit).offset(offset)).all()
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Public publication evidence from the council export and employee database."""
|
||||
|
||||
import calendar
|
||||
import csv
|
||||
import re
|
||||
import unicodedata
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import date, datetime
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote, urlsplit
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
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)
|
||||
TYPES = {"ARTICLE": "Статья", "BOOK": "Книга", "CHAPTER": "Глава книги",
|
||||
"BOOK_CHAPTER": "Глава книги", "REPORT": "Доклад", "PATENT": "Патент",
|
||||
"PREPRINT": "Препринт", "CONFERENCE_PAPER": "Материал конференции"}
|
||||
|
||||
|
||||
def _name(value: str) -> str:
|
||||
return " ".join(unicodedata.normalize("NFKC", value).casefold().replace("ё", "е").split())
|
||||
|
||||
|
||||
def _doi(value: str) -> str:
|
||||
value = re.sub(r"^(?:https?://(?:dx\.)?doi\.org/|doi:\s*)", "", value.strip(), flags=re.I).lower()
|
||||
return value if DOI.fullmatch(value) else ""
|
||||
|
||||
|
||||
def _safe_url(value: str | None) -> str | None:
|
||||
try:
|
||||
url = urlsplit(value or "")
|
||||
if url.scheme in {"http", "https"} and url.hostname and not url.username and not url.password:
|
||||
return value
|
||||
except ValueError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def period(today: date) -> tuple[date, date]:
|
||||
year = today.year - 5
|
||||
return date(year, today.month, min(today.day, calendar.monthrange(year, today.month)[1])), today
|
||||
|
||||
|
||||
def _quartiles(row: dict) -> list[str]:
|
||||
return [f"{label} {str(row.get(field) or '').strip().upper()}"
|
||||
for label, field in (("Scopus", "Квартиль scopus"), ("Web of Science", "Квартиль wos"))
|
||||
if str(row.get(field) or '').strip().upper() in {"Q1", "Q2"}]
|
||||
|
||||
|
||||
@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:
|
||||
reader = csv.DictReader(source, delimiter=";")
|
||||
required = {"Название публикации", "Год", "DOI", "Авторы НИУ ВШЭ", "Квартиль scopus", "Квартиль wos"}
|
||||
if not required <= set(reader.fieldnames or []):
|
||||
raise ValueError("Missing council CSV headers")
|
||||
for row_number, row in enumerate(reader, 2):
|
||||
if None in row or any(value is None for value in row.values()):
|
||||
raise ValueError("Malformed council CSV record")
|
||||
title = row["Название публикации"].strip()
|
||||
if not title:
|
||||
continue
|
||||
year = int(row["Год"]) if row["Год"].strip().isdigit() else None
|
||||
article = {"title": title, "year": year, "doi": _doi(row["DOI"]),
|
||||
"types": [], "quartiles": _quartiles(row), "origins": ["Файл CSV"],
|
||||
"rows": [row_number], "in_csv": True, "in_db": False, "url": None}
|
||||
for author in set(_name(value) for value in row["Авторы НИУ ВШЭ"].split(";")) - {""}:
|
||||
articles[author].append(article)
|
||||
return articles
|
||||
|
||||
|
||||
def _database_article(publication) -> dict:
|
||||
raw = publication.raw_data if isinstance(publication.raw_data, dict) else {}
|
||||
imported = raw.get("csv_enrichment")
|
||||
from_site = bool(publication.publication_id or publication.url or any(key != "csv_enrichment" for key in raw))
|
||||
origins = ["БД miem-workers · сайт ВШЭ"] if from_site else []
|
||||
if imported:
|
||||
origins.append("БД miem-workers · импорт CSV")
|
||||
kind = publication.publication_type
|
||||
return {"title": publication.title, "year": publication.year, "doi": _doi(publication.doi_url or ""),
|
||||
"types": [TYPES.get(kind.upper(), f"Другой тип: {kind}")] if kind else [],
|
||||
"quartiles": _quartiles(imported) if isinstance(imported, dict) else [],
|
||||
"origins": origins or ["БД miem-workers"], "rows": [], "in_csv": False, "in_db": True,
|
||||
"url": _safe_url(publication.url)}
|
||||
|
||||
|
||||
def _unique_articles(articles: list[dict], start: date, end: date) -> list[dict]:
|
||||
title_years = defaultdict(set)
|
||||
for article in articles:
|
||||
if article["year"] is not None:
|
||||
title_years[_name(article["title"])].add(article["year"])
|
||||
def title_key(article):
|
||||
title = _name(article["title"])
|
||||
years = title_years[title]
|
||||
year = article["year"]
|
||||
return title, next(iter(years)) if year is None and len(years) == 1 else year
|
||||
|
||||
# A title/year fallback is safe only when it identifies at most one DOI.
|
||||
title_dois = defaultdict(set)
|
||||
for article in articles:
|
||||
if article["doi"]:
|
||||
title_dois[title_key(article)].add(article["doi"])
|
||||
unique = {}
|
||||
for article in articles:
|
||||
fallback = title_key(article)
|
||||
identifiers = title_dois[fallback]
|
||||
identifier = article["doi"] or (next(iter(identifiers)) if len(identifiers) == 1 else "")
|
||||
key = identifier or fallback
|
||||
if key not in unique:
|
||||
unique[key] = {**article, "doi": identifier,
|
||||
**{field: list(article[field]) for field in ("types", "quartiles", "origins", "rows")},
|
||||
"csv_qualifies": False}
|
||||
merged = unique[key]
|
||||
for field in ("types", "quartiles", "origins", "rows"):
|
||||
merged[field] = list(dict.fromkeys([*merged[field], *article[field]]))
|
||||
merged["in_csv"] |= article["in_csv"]
|
||||
merged["in_db"] |= article["in_db"]
|
||||
merged["url"] = merged["url"] or article["url"]
|
||||
merged["year"] = merged["year"] or article["year"]
|
||||
in_period = article["year"] is not None and start.year <= article["year"] <= end.year
|
||||
merged["csv_qualifies"] |= bool(article["in_csv"] and article["quartiles"] and in_period)
|
||||
for article in unique.values():
|
||||
year = article["year"]
|
||||
article["criteria"] = list(article["quartiles"])
|
||||
if year is None:
|
||||
article["criteria"].append("Год не указан — период не подтверждён")
|
||||
elif start.year <= year <= end.year:
|
||||
article["criteria"].append("В пределах пяти лет по году; точную дату нужно проверить" if year in {start.year, end.year}
|
||||
else "В пределах пяти лет")
|
||||
else:
|
||||
article["criteria"].append("Вне пятилетнего периода")
|
||||
if not article["quartiles"]:
|
||||
article["criteria"].append("Q1/Q2, К1 и RSCI не подтверждены доступными данными")
|
||||
article["doi_url"] = f"https://doi.org/{quote(article['doi'], safe='/')}" if article["doi"] else None
|
||||
article["type_label"] = " / ".join(article["types"]) or "Тип не указан — статья не подтверждена"
|
||||
if article["types"] and article["types"] != ["Статья"]:
|
||||
article["csv_qualifies"] = False
|
||||
article["criteria"].append("Тип материала не подтверждён как статья — не включён в счётчик CSV")
|
||||
return sorted(unique.values(), key=lambda item: (not item["csv_qualifies"], -(item["year"] or 0), item["title"].casefold()))
|
||||
|
||||
|
||||
def council_candidates(db: Session, today: date | None = None, q: str = "", offset: int = 0,
|
||||
sort: str = "csv_count", direction: str = "desc", paginate: bool = True) -> dict:
|
||||
today = today or datetime.now(ZoneInfo("Europe/Moscow")).date()
|
||||
start, end = period(today)
|
||||
employees = db.scalars(select(Employee).options(selectinload(Employee.publications))).all()
|
||||
name_counts = Counter(_name(employee.full_name or "") for employee in employees)
|
||||
by_author = _articles_by_author()
|
||||
candidates = []
|
||||
for employee in employees:
|
||||
author = _name(employee.full_name or "")
|
||||
ambiguous = bool(author and name_counts[author] > 1 and by_author.get(author))
|
||||
csv_articles = by_author.get(author, []) if author and name_counts[author] == 1 else []
|
||||
articles = _unique_articles([*csv_articles, *(_database_article(p) for p in employee.publications)], start, end)
|
||||
if not articles:
|
||||
continue
|
||||
data = employee.current_data if isinstance(employee.current_data, dict) else {}
|
||||
try:
|
||||
degrees = academic_degrees(data)
|
||||
except (AttributeError, TypeError):
|
||||
degrees = []
|
||||
if not any(DOCTOR.fullmatch(degree) for degree in degrees):
|
||||
continue
|
||||
candidates.append({"name": employee.full_name or "ФИО не указано", "degree": "; ".join(degrees) or "Учёная степень не указана",
|
||||
"count": len(articles), "csv_count": sum(a["csv_qualifies"] for a in articles),
|
||||
"db_count": sum(a["in_db"] for a in articles), "articles": articles,
|
||||
"profile_url": _safe_url(employee.canonical_url), "ambiguous": ambiguous,
|
||||
"dismissed": employee.status == "dismissed"})
|
||||
sort = sort if sort in {"full_name", "csv_count", "db_count", "count"} else "csv_count"
|
||||
direction = direction if direction in {"asc", "desc"} else "desc"
|
||||
candidates.sort(key=lambda item: _name(item["name"]), reverse=sort == "full_name" and direction == "desc")
|
||||
if sort != "full_name":
|
||||
candidates.sort(key=lambda item: (item[sort], item["count"]), reverse=direction == "desc")
|
||||
total = len(candidates)
|
||||
query = _name(q)
|
||||
candidates = [item for item in candidates if query in _name(item["name"])]
|
||||
matched = len(candidates)
|
||||
limit = 25
|
||||
offset = min(max(0, offset), ((matched - 1) // limit) * limit) if matched else 0
|
||||
return {"candidates": candidates[offset:offset + limit] if paginate else candidates,
|
||||
"start": start, "end": end, "total": total,
|
||||
"matched": matched, "q": q, "offset": offset, "limit": limit, "sort": sort, "direction": direction}
|
||||
@@ -0,0 +1,70 @@
|
||||
"""XLSX report containing the same public evidence as the council page."""
|
||||
|
||||
from io import BytesIO
|
||||
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE
|
||||
from openpyxl.styles import Alignment, Font, PatternFill
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
|
||||
def _append(sheet, values):
|
||||
values = [ILLEGAL_CHARACTERS_RE.sub("", value) if isinstance(value, str) else value for value in values]
|
||||
if any(isinstance(value, str) and len(value) > 32767 for value in values):
|
||||
raise ValueError("Text exceeds Excel cell limit")
|
||||
sheet.append(values)
|
||||
|
||||
|
||||
def council_xlsx(result: dict) -> bytes:
|
||||
workbook = Workbook()
|
||||
workbook.properties.creator = "MIEM Employees"
|
||||
employees = workbook.active
|
||||
employees.title = "Сотрудники"
|
||||
publications = workbook.create_sheet("Публикации")
|
||||
parameters = workbook.create_sheet("Параметры")
|
||||
_append(employees, ["№", "ФИО", "Учёная степень", "CSV по критериям", "В БД", "Всего без дублей",
|
||||
"Статус", "Профиль ВШЭ", "Примечание"])
|
||||
_append(publications, ["№ сотрудника", "ФИО", "Название публикации", "Год", "Тип материала", "Источники",
|
||||
"Критерии", "В счётчике CSV", "В БД", "DOI", "Ссылка на сайте", "Строки CSV"])
|
||||
for number, candidate in enumerate(result["candidates"], 1):
|
||||
note = "Совпадение ФИО: публикации CSV не присвоены автоматически" if candidate["ambiguous"] else ""
|
||||
_append(employees, [number, candidate["name"], candidate["degree"], candidate["csv_count"],
|
||||
candidate["db_count"], candidate["count"], "Уволен" if candidate["dismissed"] else "Не отмечен как уволенный",
|
||||
candidate["profile_url"], note])
|
||||
for article in candidate["articles"]:
|
||||
_append(publications, [number, candidate["name"], article["title"], article["year"], article["type_label"],
|
||||
"; ".join(article["origins"]), "; ".join(article["criteria"]),
|
||||
"Да" if article["csv_qualifies"] else "Нет", "Да" if article["in_db"] else "Нет",
|
||||
article["doi_url"], article["url"], ", ".join(map(str, article["rows"]))])
|
||||
_append(parameters, ["Параметр", "Значение"])
|
||||
for values in [
|
||||
["Начало периода", result["start"]], ["Конец периода", result["end"]], ["Поиск по ФИО", result["q"]],
|
||||
["Сортировать по", {"full_name": "ФИО", "csv_count": "CSV по критериям", "db_count": "В БД",
|
||||
"count": "Всего без дублей"}[result["sort"]]],
|
||||
["Направление", "По возрастанию" if result["direction"] == "asc" else "По убыванию"],
|
||||
["Сотрудников в выгрузке", result["matched"]], ["Всего сотрудников в списке", result["total"]],
|
||||
["Отбор по степени", "Доктор наук в профиле; достаточно хотя бы одной публикации из CSV или БД"],
|
||||
["Счётчик CSV", "Предварительный: Q1/Q2 Scopus или Web of Science в пятилетнем периоде по году. "
|
||||
"Для пограничных лет нужно проверить дату, для неизвестного типа — подтвердить, что это статья. "
|
||||
"Материалы с известным другим типом исключены. К1/RSCI не подтверждены доступными данными."],
|
||||
]:
|
||||
_append(parameters, values)
|
||||
for sheet, widths in [(employees, [7, 34, 40, 22, 12, 22, 30, 45, 55]),
|
||||
(publications, [16, 34, 65, 12, 40, 45, 65, 22, 12, 45, 45, 22]),
|
||||
(parameters, [32, 95])]:
|
||||
sheet.freeze_panes = "A2"
|
||||
sheet.auto_filter.ref = sheet.dimensions
|
||||
for column, width in enumerate(widths, 1):
|
||||
sheet.column_dimensions[get_column_letter(column)].width = width
|
||||
for row in sheet:
|
||||
for cell in row:
|
||||
if isinstance(cell.value, str):
|
||||
# CSV/profile content is text, never an Excel formula or error code.
|
||||
cell.data_type = "s"
|
||||
cell.alignment = Alignment(vertical="top", wrap_text=True)
|
||||
for cell in sheet[1]:
|
||||
cell.font = Font(bold=True, color="FFFFFF")
|
||||
cell.fill = PatternFill("solid", fgColor="263640")
|
||||
output = BytesIO()
|
||||
workbook.save(output)
|
||||
return output.getvalue()
|
||||
+60
-14
@@ -2,7 +2,8 @@ import gzip
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import time
|
||||
import unicodedata
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import requests
|
||||
@@ -26,7 +27,6 @@ from app.parser.collector import collect_profile_links
|
||||
from app.parser.profile import parse_person_profile
|
||||
from app.parser.profile_url import profile_key
|
||||
from app.services.academic_degrees import academic_degrees
|
||||
from app.services.dataset_versions import get_or_create_current_version
|
||||
from app.services.resource_cache import ResourceCache
|
||||
|
||||
HEADERS = {
|
||||
@@ -102,7 +102,6 @@ def run_crawl(db: Session, settings: Settings) -> CrawlRun:
|
||||
max_auto_dismissals=settings.max_auto_dismissals_per_run,
|
||||
)
|
||||
run.status = "completed"
|
||||
get_or_create_current_version(db, crawl_run_id=run.id)
|
||||
except Exception as exc:
|
||||
run.status = "failed"
|
||||
run.message = str(exc)
|
||||
@@ -146,7 +145,6 @@ def refresh_dismissed_status(db: Session, settings: Settings) -> CrawlRun:
|
||||
)
|
||||
run.parsed_count += 1
|
||||
run.status = "completed"
|
||||
get_or_create_current_version(db, crawl_run_id=run.id)
|
||||
except Exception as exc:
|
||||
run.status = "failed"
|
||||
run.error_count = 1
|
||||
@@ -194,7 +192,6 @@ def refresh_employee(db: Session, employee: Employee, settings: Settings) -> Cra
|
||||
else:
|
||||
run.skipped_count = 1
|
||||
run.status = "completed"
|
||||
get_or_create_current_version(db, crawl_run_id=run.id)
|
||||
except Exception as exc:
|
||||
run.status = "failed"
|
||||
run.error_count = 1
|
||||
@@ -402,8 +399,11 @@ def _employee_publications_table_exists(db: Session) -> bool:
|
||||
return inspect(db.connection()).has_table(EmployeePublication.__tablename__)
|
||||
|
||||
|
||||
def _sync_employee_publications(db: Session, employee: Employee, parsed: dict) -> None:
|
||||
publications = _publication_payloads(parsed)
|
||||
def _sync_employee_publications(db: Session, employee: Employee, parsed: dict) -> None:
|
||||
publications = _publication_payloads(parsed)
|
||||
imported = [item for item in db.scalars(select(EmployeePublication).where(
|
||||
EmployeePublication.employee_id == employee.id
|
||||
)).all() if _csv_enrichment(item) is not None]
|
||||
seen_hashes = set()
|
||||
for publication in publications:
|
||||
source_hash = _publication_hash(publication)
|
||||
@@ -424,8 +424,10 @@ def _sync_employee_publications(db: Session, employee: Employee, parsed: dict) -
|
||||
EmployeePublication.source_hash == source_hash,
|
||||
)
|
||||
)
|
||||
if not existing:
|
||||
existing = EmployeePublication(employee_id=employee.id, source_hash=source_hash, title=_publication_title(publication))
|
||||
if not existing:
|
||||
existing = _match_imported_publication(imported, publication)
|
||||
if not existing:
|
||||
existing = EmployeePublication(employee_id=employee.id, source_hash=source_hash, title=_publication_title(publication))
|
||||
db.add(existing)
|
||||
_apply_publication(existing, publication, source_hash)
|
||||
|
||||
@@ -436,8 +438,39 @@ def _sync_employee_publications(db: Session, employee: Employee, parsed: dict) -
|
||||
EmployeePublication.source_hash.not_in(seen_hashes),
|
||||
)
|
||||
).all()
|
||||
for item in stale:
|
||||
db.delete(item)
|
||||
for item in stale:
|
||||
if _csv_enrichment(item) is None:
|
||||
db.delete(item)
|
||||
|
||||
|
||||
def _csv_enrichment(publication: EmployeePublication) -> dict | None:
|
||||
raw = publication.raw_data
|
||||
metadata = raw.get("csv_enrichment") if isinstance(raw, dict) else None
|
||||
return metadata if isinstance(metadata, dict) else None
|
||||
|
||||
|
||||
def _match_imported_publication(imported: list[EmployeePublication], publication: dict) -> EmployeePublication | None:
|
||||
def doi(value):
|
||||
return re.sub(r"^(?:https?://(?:dx\.)?doi\.org/|doi:\s*)", "", (value or "").strip(), flags=re.I).lower()
|
||||
|
||||
def title(value):
|
||||
return " ".join(unicodedata.normalize("NFKC", value or "").casefold().replace("ё", "е").split())
|
||||
|
||||
identifier = doi(publication.get("doi_url"))
|
||||
year = _int_or_none(publication.get("year"))
|
||||
name = title(publication.get("title") or publication.get("text"))
|
||||
matches = [item for item in imported if
|
||||
(identifier and doi(item.doi_url) == identifier) or
|
||||
(name and title(item.title) == name and item.year in (None, year))]
|
||||
if len(matches) > 1:
|
||||
raise ValueError("Ambiguous imported publication; synchronization rolled back")
|
||||
if not matches:
|
||||
return None
|
||||
match = matches[0]
|
||||
if ((identifier and doi(match.doi_url) and identifier != doi(match.doi_url)) or
|
||||
(year and match.year and year != match.year)):
|
||||
raise ValueError("Conflicting imported publication; synchronization rolled back")
|
||||
return match
|
||||
|
||||
|
||||
def _publication_payloads(parsed: dict) -> list[dict]:
|
||||
@@ -451,8 +484,16 @@ def _publication_payloads(parsed: dict) -> list[dict]:
|
||||
return publications
|
||||
|
||||
|
||||
def _apply_publication(target: EmployeePublication, publication: dict, source_hash: str) -> None:
|
||||
target.publication_id = _clean_optional(publication.get("publication_id") or publication.get("id"))
|
||||
def _apply_publication(target: EmployeePublication, publication: dict, source_hash: str) -> None:
|
||||
enrichment = _csv_enrichment(target)
|
||||
publication_id = _clean_optional(publication.get("publication_id") or publication.get("id"))
|
||||
if enrichment is not None and target.publication_id and publication_id and target.publication_id != publication_id:
|
||||
raise ValueError(
|
||||
f"Ambiguous HSE IDs for CSV publication {target.id}: "
|
||||
f"{target.publication_id} != {publication_id}; publication synchronization rolled back"
|
||||
)
|
||||
preserved = {field: getattr(target, field) for field in ("title", "year", "doi_url")}
|
||||
target.publication_id = (publication_id or target.publication_id) if enrichment is not None else publication_id
|
||||
target.title = _publication_title(publication)
|
||||
target.year = _int_or_none(publication.get("year"))
|
||||
target.publication_type = _clean_optional(publication.get("publication_type") or publication.get("type"))
|
||||
@@ -466,7 +507,12 @@ def _apply_publication(target: EmployeePublication, publication: dict, source_ha
|
||||
target.annotation = publication.get("annotation") if isinstance(publication.get("annotation"), dict) else None
|
||||
target.description = publication.get("description") if isinstance(publication.get("description"), dict) else None
|
||||
target.authors = publication.get("authors") if isinstance(publication.get("authors"), list) else None
|
||||
target.raw_data = publication.get("raw_data") if isinstance(publication.get("raw_data"), dict) else publication
|
||||
target.raw_data = publication.get("raw_data") if isinstance(publication.get("raw_data"), dict) else publication
|
||||
if enrichment is not None:
|
||||
target.raw_data = {**target.raw_data, "csv_enrichment": enrichment}
|
||||
for field in ("title", "year", "doi_url"):
|
||||
if not getattr(target, field) or (field == "title" and not (publication.get("title") or publication.get("text"))):
|
||||
setattr(target, field, preserved[field])
|
||||
target.source_hash = source_hash
|
||||
|
||||
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import desc, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import DatasetVersion, DatasetVersionItem, Employee
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EmployeeMarker:
|
||||
profile_key: str
|
||||
employee_id: int | None
|
||||
status: str
|
||||
checksum: str
|
||||
|
||||
|
||||
def get_or_create_current_version(db: Session, *, crawl_run_id: int | None = None) -> DatasetVersion:
|
||||
employees = db.scalars(select(Employee).order_by(Employee.profile_key)).all()
|
||||
markers = [_employee_marker(employee) for employee in employees]
|
||||
dataset_hash = _dataset_hash(markers)
|
||||
latest = get_latest_version(db)
|
||||
if latest and latest.hash == dataset_hash:
|
||||
return latest
|
||||
|
||||
active_count = sum(1 for marker in markers if marker.status == "active")
|
||||
dismissed_count = sum(1 for marker in markers if marker.status == "dismissed")
|
||||
version = DatasetVersion(
|
||||
hash=dataset_hash,
|
||||
previous_hash=latest.hash if latest else None,
|
||||
crawl_run_id=crawl_run_id,
|
||||
employee_count=len(markers),
|
||||
active_count=active_count,
|
||||
dismissed_count=dismissed_count,
|
||||
)
|
||||
db.add(version)
|
||||
db.flush()
|
||||
for marker in markers:
|
||||
db.add(
|
||||
DatasetVersionItem(
|
||||
dataset_version_id=version.id,
|
||||
profile_key=marker.profile_key,
|
||||
employee_id=marker.employee_id,
|
||||
status=marker.status,
|
||||
checksum=marker.checksum,
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
return version
|
||||
|
||||
|
||||
def get_latest_version(db: Session) -> DatasetVersion | None:
|
||||
return db.scalar(select(DatasetVersion).order_by(desc(DatasetVersion.created_at), desc(DatasetVersion.id)).limit(1))
|
||||
|
||||
|
||||
def get_version_by_hash(db: Session, dataset_hash: str | None) -> DatasetVersion | None:
|
||||
if not dataset_hash:
|
||||
return None
|
||||
return db.scalar(select(DatasetVersion).where(DatasetVersion.hash == dataset_hash).limit(1))
|
||||
|
||||
|
||||
def service_info_payload(db: Session, *, tools: list[dict], service_name: str, backend_version: str, protocol_version: str) -> dict:
|
||||
version = get_or_create_current_version(db)
|
||||
db.commit()
|
||||
return {
|
||||
"service_name": service_name,
|
||||
"backend_version": backend_version,
|
||||
"protocolVersion": protocol_version,
|
||||
"tools": tools,
|
||||
"dataset": _version_payload(version),
|
||||
}
|
||||
|
||||
|
||||
def sync_employees_payload(db: Session, *, client_hash: str | None = None, include_data: bool = True) -> dict:
|
||||
current = get_or_create_current_version(db)
|
||||
db.commit()
|
||||
if not client_hash:
|
||||
return _full_sync_payload(db, current, include_data=include_data, reason=None)
|
||||
if client_hash == current.hash:
|
||||
return {
|
||||
"mode": "delta",
|
||||
"from_hash": client_hash,
|
||||
"to_hash": current.hash,
|
||||
"dataset": _version_payload(current),
|
||||
"changes": {"added": [], "updated": [], "dismissed": [], "removed": []},
|
||||
}
|
||||
|
||||
previous = get_version_by_hash(db, client_hash)
|
||||
if not previous:
|
||||
return _full_sync_payload(db, current, include_data=include_data, reason="unknown_client_hash", from_hash=client_hash)
|
||||
|
||||
return _delta_sync_payload(db, previous, current, include_data=include_data)
|
||||
|
||||
|
||||
def _full_sync_payload(
|
||||
db: Session,
|
||||
current: DatasetVersion,
|
||||
*,
|
||||
include_data: bool,
|
||||
reason: str | None,
|
||||
from_hash: str | None = None,
|
||||
) -> dict:
|
||||
employees = db.scalars(select(Employee).order_by(Employee.profile_key)).all()
|
||||
payload = {
|
||||
"mode": "full",
|
||||
"from_hash": from_hash,
|
||||
"to_hash": current.hash,
|
||||
"dataset": _version_payload(current),
|
||||
"items": [_employee_payload(employee, include_data=include_data) for employee in employees],
|
||||
}
|
||||
if reason:
|
||||
payload["reason"] = reason
|
||||
return payload
|
||||
|
||||
|
||||
def _delta_sync_payload(db: Session, previous: DatasetVersion, current: DatasetVersion, *, include_data: bool) -> dict:
|
||||
previous_items = _items_by_profile_key(previous)
|
||||
current_items = _items_by_profile_key(current)
|
||||
employees = {employee.profile_key: employee for employee in db.scalars(select(Employee)).all()}
|
||||
added = []
|
||||
updated = []
|
||||
dismissed = []
|
||||
removed = []
|
||||
|
||||
for profile_key, current_item in sorted(current_items.items()):
|
||||
previous_item = previous_items.get(profile_key)
|
||||
employee = employees.get(profile_key)
|
||||
if not previous_item:
|
||||
if employee:
|
||||
added.append(_employee_payload(employee, include_data=include_data))
|
||||
continue
|
||||
if previous_item.checksum == current_item.checksum and previous_item.status == current_item.status:
|
||||
continue
|
||||
if current_item.status == "dismissed":
|
||||
dismissed.append(_tombstone(profile_key, current_item.status, employee))
|
||||
elif employee:
|
||||
updated.append(_employee_payload(employee, include_data=include_data))
|
||||
|
||||
for profile_key, previous_item in sorted(previous_items.items()):
|
||||
if profile_key not in current_items:
|
||||
removed.append(_tombstone(profile_key, "removed", employees.get(profile_key), checksum=previous_item.checksum))
|
||||
|
||||
return {
|
||||
"mode": "delta",
|
||||
"from_hash": previous.hash,
|
||||
"to_hash": current.hash,
|
||||
"dataset": _version_payload(current),
|
||||
"changes": {
|
||||
"added": added,
|
||||
"updated": updated,
|
||||
"dismissed": dismissed,
|
||||
"removed": removed,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _items_by_profile_key(version: DatasetVersion) -> dict[str, DatasetVersionItem]:
|
||||
return {item.profile_key: item for item in version.items}
|
||||
|
||||
|
||||
def _version_payload(version: DatasetVersion) -> dict:
|
||||
return {
|
||||
"hash": version.hash,
|
||||
"previous_hash": version.previous_hash,
|
||||
"created_at": version.created_at.isoformat() if version.created_at else None,
|
||||
"crawl_run_id": version.crawl_run_id,
|
||||
"employee_count": version.employee_count,
|
||||
"active_count": version.active_count,
|
||||
"dismissed_count": version.dismissed_count,
|
||||
}
|
||||
|
||||
|
||||
def _employee_marker(employee: Employee) -> EmployeeMarker:
|
||||
return EmployeeMarker(
|
||||
profile_key=employee.profile_key,
|
||||
employee_id=employee.id,
|
||||
status=employee.status,
|
||||
checksum=employee.current_checksum or _payload_hash(employee.current_data or {}),
|
||||
)
|
||||
|
||||
|
||||
def _dataset_hash(markers: list[EmployeeMarker]) -> str:
|
||||
payload = [
|
||||
{"profile_key": marker.profile_key, "status": marker.status, "checksum": marker.checksum}
|
||||
for marker in sorted(markers, key=lambda item: item.profile_key)
|
||||
]
|
||||
return _payload_hash(payload)
|
||||
|
||||
|
||||
def _payload_hash(value: object) -> str:
|
||||
payload = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str)
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _employee_payload(employee: Employee, *, include_data: bool) -> dict:
|
||||
payload = {
|
||||
"profile_key": employee.profile_key,
|
||||
"profile_id": employee.profile_id,
|
||||
"full_name": employee.full_name,
|
||||
"status": employee.status,
|
||||
"canonical_url": employee.canonical_url,
|
||||
"last_seen_at": employee.last_seen_at.isoformat() if employee.last_seen_at else None,
|
||||
"dismissed_at": employee.dismissed_at.isoformat() if employee.dismissed_at else None,
|
||||
"checksum": employee.current_checksum or _payload_hash(employee.current_data or {}),
|
||||
}
|
||||
if include_data:
|
||||
payload["data"] = employee.current_data
|
||||
return payload
|
||||
|
||||
|
||||
def _tombstone(profile_key: str, status: str, employee: Employee | None, *, checksum: str | None = None) -> dict:
|
||||
payload = {
|
||||
"profile_key": profile_key,
|
||||
"status": status,
|
||||
"checksum": checksum or (employee.current_checksum if employee else None),
|
||||
}
|
||||
if employee:
|
||||
payload.update(
|
||||
{
|
||||
"profile_id": employee.profile_id,
|
||||
"full_name": employee.full_name,
|
||||
"canonical_url": employee.canonical_url,
|
||||
"dismissed_at": employee.dismissed_at.isoformat() if employee.dismissed_at else None,
|
||||
}
|
||||
)
|
||||
return payload
|
||||
+305
-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,140 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
@media (max-width: 620px) {
|
||||
.directory-table [data-column="full_name"] {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
min-width: 130px;
|
||||
max-width: 160px;
|
||||
background: #ffffff;
|
||||
}
|
||||
.admin__header {
|
||||
align-items: flex-start;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.admin__nav {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.admin__main {
|
||||
width: min(100% - 24px, 1180px);
|
||||
margin: 20px auto;
|
||||
}
|
||||
|
||||
.directory__filters {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.directory__filter-group,
|
||||
.directory__filter-group--search,
|
||||
.directory__filter-actions {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.directory__field {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.council {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
color: #25313a;
|
||||
}
|
||||
|
||||
.council__hero {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 32px;
|
||||
padding: clamp(24px, 4vw, 48px);
|
||||
color: #f9f6f0;
|
||||
background: #263640;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.council__hero-copy { max-width: 680px; }
|
||||
.council__title { margin: 0; font-family: Georgia, serif; font-size: clamp(29px, 4vw, 48px); line-height: 1.12; letter-spacing: -0.025em; }
|
||||
.council__intro { max-width: 60ch; margin: 16px 0 0; color: #e3e8e5; line-height: 1.55; }
|
||||
.council__total { margin: 0; min-width: 160px; color: #e3e8e5; font-size: 14px; line-height: 1.4; }
|
||||
.council__total strong { display: block; color: #f6d49b; font-size: 42px; font-variant-numeric: tabular-nums; line-height: 1; }
|
||||
|
||||
.council__criteria {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
padding-block: 6px 22px;
|
||||
border-bottom: 1px solid #cdd4d5;
|
||||
}
|
||||
|
||||
.council__criteria p { display: grid; align-content: start; gap: 5px; margin: 0; }
|
||||
.council__criteria strong { font-size: 18px; font-variant-numeric: tabular-nums; }
|
||||
.council__criteria span { color: #4c5b64; font-size: 13px; }
|
||||
.council__notice { padding: 16px 20px; background: #f6efe2; border-radius: 10px; line-height: 1.55; }
|
||||
.council__notice strong { color: #593b20; }
|
||||
.council__list-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
|
||||
.council__list-heading h3 { margin: 0; font-family: Georgia, serif; font-size: 27px; }
|
||||
.council__list-heading p { margin: 0; color: #4c5b64; font-size: 14px; }
|
||||
.council__list { display: grid; gap: 10px; }
|
||||
|
||||
.council__candidate { overflow: hidden; background: #fff; border: 1px solid #d6dddc; border-radius: 12px; }
|
||||
.council__candidate[open] { border-color: #8fa8a5; }
|
||||
.council__candidate-summary { display: grid; grid-template-columns: minmax(0, 1fr) auto 18px; align-items: center; gap: 20px; min-height: 76px; padding: 14px 20px; cursor: pointer; list-style: none; }
|
||||
.council__candidate-summary::-webkit-details-marker { display: none; }
|
||||
.council__candidate-summary:hover { background: #f3f7f5; }
|
||||
.council__candidate-identity { display: grid; gap: 4px; min-width: 0; }
|
||||
.council__candidate-identity strong { font-size: 17px; overflow-wrap: anywhere; }
|
||||
.council__candidate-identity small, .council__candidate-count small { color: #4c5b64; font-size: 12px; }
|
||||
.council__candidate-count { display: grid; gap: 2px; text-align: end; }
|
||||
.council__candidate-count strong { font-size: 25px; font-variant-numeric: tabular-nums; line-height: 1; }
|
||||
.council__candidate-cue { width: 9px; height: 9px; border-inline-end: 2px solid #345e59; border-bottom: 2px solid #345e59; transform: rotate(45deg); }
|
||||
.council__candidate[open] .council__candidate-cue { transform: rotate(225deg); }
|
||||
.council__candidate-body { padding: 20px; border-top: 1px solid #e4e9e8; }
|
||||
.council__articles { display: grid; gap: 0; margin: 16px 0 0; padding-inline-start: 22px; }
|
||||
.council__article { padding: 14px 4px; border-top: 1px solid #e7ebea; }
|
||||
.council__article::marker { color: #5e746f; font-variant-numeric: tabular-nums; }
|
||||
.council__article-heading { display: flex; justify-content: space-between; gap: 16px; line-height: 1.45; }
|
||||
.council__article-heading strong { font-weight: 600; overflow-wrap: anywhere; }
|
||||
.council__article-heading span { color: #4c5b64; font-variant-numeric: tabular-nums; }
|
||||
.council__article-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 12px; margin-top: 9px; color: #4c5b64; font-size: 12px; }
|
||||
.council__source { padding: 4px 8px; color: #244b46; background: #e7f0eb; border-radius: 4px; font-weight: 700; overflow-wrap: anywhere; }
|
||||
.council__message { padding: 28px; background: #fff; border-radius: 12px; }
|
||||
.council__message h3, .council__message h4 { margin: 0 0 8px; font-size: 19px; }
|
||||
.council__message p { margin: 0; color: #4c5b64; line-height: 1.5; }
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.council__hero, .council__list-heading { align-items: start; flex-direction: column; }
|
||||
.council__total strong { display: inline; margin-inline-end: 8px; font-size: 28px; }
|
||||
.council__criteria { grid-template-columns: 1fr; gap: 14px; }
|
||||
.council__candidate-summary { grid-template-columns: minmax(0, 1fr) auto 14px; gap: 10px; padding: 14px; }
|
||||
.council__candidate-summary .council__candidate-identity { grid-column: 1; }
|
||||
.council__candidate-cue { grid-column: 2; grid-row: 1; }
|
||||
.council__candidate-summary { grid-template-columns: minmax(0, 1fr) 14px; }
|
||||
.council__counts { grid-column: 1 / -1; justify-content: space-between; }
|
||||
.council__candidate-count { text-align: start; }
|
||||
.council__candidate-body { padding: 14px; }
|
||||
}
|
||||
|
||||
.council__search { display: flex; align-items: end; flex-wrap: wrap; gap: 12px; }
|
||||
.council__search .directory__field { flex: 1 1 240px; max-width: 520px; }
|
||||
.council__notice summary { cursor: pointer; padding-block: 6px; }
|
||||
.council__notice p { max-width: 75ch; margin-bottom: 0; }
|
||||
.council__counts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: clamp(12px, 3vw, 36px); }
|
||||
.council__candidate-count small { max-width: 105px; line-height: 1.4; }
|
||||
.council__hint { margin: 0; color: #4c5b64; font-size: 14px; line-height: 1.5; }
|
||||
.council__candidate-body .council__hint { margin-top: 12px; }
|
||||
.council__evidence { display: flex; flex-wrap: wrap; gap: 4px 20px; padding-inline-start: 18px; margin: 10px 0 0; color: #4c5b64; font-size: 13px; line-height: 1.5; }
|
||||
.council__article-heading span { flex-shrink: 0; }
|
||||
.council ::selection { background: #ccfbf1; color: #25313a; }
|
||||
.council input { caret-color: #0f766e; }
|
||||
.council a { text-underline-offset: 3px; }
|
||||
.council__article { overflow-wrap: anywhere; }
|
||||
@media (max-width: 400px) {
|
||||
.council__title { font-size: clamp(23px, 7vw, 29px); overflow-wrap: anywhere; }
|
||||
.council__article-heading { flex-direction: column; gap: 4px; }
|
||||
}
|
||||
+57
-32
@@ -3,14 +3,15 @@
|
||||
"full_name",
|
||||
"status",
|
||||
"positions",
|
||||
"hse_start_year",
|
||||
"email",
|
||||
"academic_degree",
|
||||
"last_seen_at",
|
||||
"dismissed_at",
|
||||
"profile",
|
||||
];
|
||||
const storageKey = "miem.directory.columns";
|
||||
const columnPresets = {
|
||||
review: ["full_name", "status", "academic_degree", "last_seen_at", "profile"],
|
||||
contacts: ["full_name", "status", "email", "phone", "address", "profile"],
|
||||
full: ["full_name", "status", "positions", "hse_start_year", "email", "phone", "address", "academic_degree", "publications_count", "courses_count", "news_count", "first_seen_at", "last_seen_at", "dismissed_at", "profile"],
|
||||
};
|
||||
|
||||
function readColumns() {
|
||||
try {
|
||||
@@ -40,18 +41,33 @@
|
||||
if (!document.querySelector("[data-directory-table]")) return;
|
||||
let columns = readColumns();
|
||||
const modal = document.querySelector("[data-columns-modal]");
|
||||
const trigger = document.querySelector("[data-columns-open]");
|
||||
applyColumns(columns);
|
||||
|
||||
document.querySelectorAll("[data-columns-open]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
modal.hidden = false;
|
||||
});
|
||||
trigger.addEventListener("click", () => {
|
||||
modal.showModal();
|
||||
trigger.setAttribute("aria-expanded", "true");
|
||||
modal.querySelector("input")?.focus();
|
||||
});
|
||||
document.querySelectorAll("[data-columns-close]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
modal.hidden = true;
|
||||
modal.close();
|
||||
});
|
||||
});
|
||||
document.querySelectorAll("[data-columns-preset]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
columns = columnPresets[button.dataset.columnsPreset];
|
||||
writeColumns(columns);
|
||||
applyColumns(columns);
|
||||
});
|
||||
});
|
||||
modal.addEventListener("click", (event) => {
|
||||
if (event.target === modal) modal.close();
|
||||
});
|
||||
modal.addEventListener("close", () => {
|
||||
trigger.setAttribute("aria-expanded", "false");
|
||||
trigger.focus();
|
||||
});
|
||||
document.querySelectorAll("[data-column-toggle]").forEach((checkbox) => {
|
||||
checkbox.addEventListener("change", () => {
|
||||
columns = Array.from(document.querySelectorAll("[data-column-toggle]:checked")).map((item) => item.value);
|
||||
@@ -62,28 +78,14 @@
|
||||
});
|
||||
}
|
||||
|
||||
function setupClickableRows() {
|
||||
const openRow = (row) => {
|
||||
window.location.href = row.dataset.rowHref;
|
||||
};
|
||||
|
||||
document.querySelectorAll("[data-row-href]").forEach((row) => {
|
||||
row.addEventListener("click", (event) => {
|
||||
if (event.target.closest("a, button, input, select, label")) return;
|
||||
openRow(row);
|
||||
});
|
||||
row.addEventListener("keydown", (event) => {
|
||||
if (!["Enter", " "].includes(event.key)) return;
|
||||
if (event.target.closest("a, button, input, select, label")) return;
|
||||
event.preventDefault();
|
||||
openRow(row);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setupProgress() {
|
||||
const panel = document.querySelector("[data-progress-panel]");
|
||||
if (!panel) return;
|
||||
const state = document.createElement("p");
|
||||
state.className = "progress-panel__empty";
|
||||
state.setAttribute("role", "status");
|
||||
panel.append(state);
|
||||
let pending = false;
|
||||
|
||||
const update = (run) => {
|
||||
if (!run) return;
|
||||
@@ -94,6 +96,7 @@
|
||||
const errors = document.querySelector("[data-progress-errors]");
|
||||
const fill = document.querySelector("[data-progress-fill]");
|
||||
const percent = document.querySelector("[data-progress-percent]");
|
||||
const error = document.querySelector("[data-progress-error]");
|
||||
if (status) status.textContent = run.status_display || run.status;
|
||||
if (processed) processed.textContent = run.processed_count;
|
||||
if (found) found.textContent = run.found_count;
|
||||
@@ -101,21 +104,44 @@
|
||||
if (errors) errors.textContent = run.error_count;
|
||||
if (fill) fill.style.width = `${run.progress_percent}%`;
|
||||
if (percent) percent.textContent = run.progress_percent;
|
||||
if (fill) {
|
||||
fill.parentElement.setAttribute("aria-valuenow", run.progress_percent);
|
||||
fill.parentElement.setAttribute("aria-valuetext", `${run.progress_percent}%`);
|
||||
}
|
||||
if (error) error.hidden = true;
|
||||
};
|
||||
|
||||
const poll = async () => {
|
||||
if (pending) return true;
|
||||
pending = true;
|
||||
state.textContent = "Обновляем прогресс…";
|
||||
panel.setAttribute("aria-busy", "true");
|
||||
try {
|
||||
const response = await fetch("/api/crawl-runs/latest", { credentials: "same-origin" });
|
||||
if (!response.ok) return false;
|
||||
const response = await fetch("/api/crawl-runs/latest", {
|
||||
credentials: "same-origin",
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!response.ok) throw new Error("progress request failed");
|
||||
const data = await response.json();
|
||||
const run = data.running || data.latest;
|
||||
update(run);
|
||||
state.textContent = run ? "Прогресс обновлён" : "Запусков пока нет. Запустите парсинг.";
|
||||
const error = document.querySelector("[data-progress-error]");
|
||||
if (error) error.hidden = true;
|
||||
return Boolean(data.running);
|
||||
} catch (_error) {
|
||||
return false;
|
||||
const error = document.querySelector("[data-progress-error]");
|
||||
if (error) error.hidden = false;
|
||||
state.textContent = "Показаны последние полученные данные. Прогресс может быть устаревшим.";
|
||||
return true;
|
||||
} finally {
|
||||
pending = false;
|
||||
panel.setAttribute("aria-busy", "false");
|
||||
}
|
||||
};
|
||||
|
||||
document.querySelectorAll("[data-progress-retry]").forEach((button) => button.addEventListener("click", poll));
|
||||
|
||||
const interval = window.setInterval(async () => {
|
||||
const keepGoing = await poll();
|
||||
if (!keepGoing) window.clearInterval(interval);
|
||||
@@ -123,6 +149,5 @@
|
||||
}
|
||||
|
||||
setupColumns();
|
||||
setupClickableRows();
|
||||
setupProgress();
|
||||
})();
|
||||
+13
-6
@@ -4,21 +4,28 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}MIEM Employees{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/admin.css">
|
||||
<link rel="stylesheet" href="/static/admin.css?v={{ frontend_version }}">
|
||||
</head>
|
||||
<body class="admin">
|
||||
<a class="admin__skip-link" href="#main-content">Перейти к содержимому</a>
|
||||
<header class="admin__header">
|
||||
<h1 class="admin__brand"><a class="admin__brand-link" href="/admin">MIEM Employees</a></h1>
|
||||
<h1 class="admin__brand"><a class="admin__brand-link" href="{{ '/admin/council' if public_page else '/admin' }}">MIEM Employees</a></h1>
|
||||
<nav class="admin__nav">
|
||||
<a class="admin__link" href="/admin">Обзор</a>
|
||||
<a class="admin__link" href="/admin/directory">Сотрудники</a>
|
||||
<a class="admin__link" href="/admin/runs">Запуски</a>
|
||||
{% if public_page %}
|
||||
<a class="admin__link" href="/admin/council" aria-current="page">Диссертационный совет</a>
|
||||
<a class="admin__link" href="/admin/login">Вход в админку</a>
|
||||
{% else %}
|
||||
<a class="admin__link" href="/admin"{% if request.url.path == "/admin" %} aria-current="page"{% endif %}>Обзор</a>
|
||||
<a class="admin__link" href="/admin/directory"{% if request.url.path == "/admin/directory" %} aria-current="page"{% endif %}>Сотрудники</a>
|
||||
<a class="admin__link" href="/admin/council"{% if request.url.path == "/admin/council" %} aria-current="page"{% endif %}>Диссертационный совет</a>
|
||||
<a class="admin__link" href="/admin/runs"{% if request.url.path.startswith("/admin/runs") %} aria-current="page"{% endif %}>Запуски</a>
|
||||
<form method="post" action="/admin/logout">
|
||||
<button class="button button--ghost" type="submit">Выйти</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</header>
|
||||
<main class="admin__main">
|
||||
<main class="admin__main" id="main-content">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<footer class="admin__footer">
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Диссертационный совет · MIEM Employees{% endblock %}
|
||||
{% block content %}
|
||||
<section class="council" aria-labelledby="council-title">
|
||||
<header class="council__hero">
|
||||
<div class="council__hero-copy">
|
||||
<h2 class="council__title" id="council-title">Диссертационный совет</h2>
|
||||
<p class="council__intro">Публикации сотрудников для проверки состава совета. В списке сотрудники с докторской степенью в профиле и хотя бы одной публикацией в файле CSV или базе miem-workers. Минимальный порог количества работ не применяется.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{% if error %}
|
||||
<div class="council__message" role="alert">
|
||||
<h3>{% if export_error %}Не удалось создать XLSX{% else %}Не удалось загрузить публикации{% endif %}</h3>
|
||||
<p>{% if export_error %}Не удалось выгрузить данные. Вернитесь к списку и попробуйте скачать файл позже.{% else %}Данные временно недоступны. Попробуйте обновить страницу позже.{% endif %}</p>
|
||||
<a class="admin__link" href="/admin/council">Повторить загрузку</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<details class="council__notice">
|
||||
<summary><strong>Как читать счётчики и критерии</strong></summary>
|
||||
<p>«CSV по критериям» — уникальные публикации с отметкой Scopus или Web of Science Q1/Q2 за {{ start.strftime('%d.%m.%Y') }} — {{ end.strftime('%d.%m.%Y') }}. «В БД» — все уникальные публикации сотрудника из miem-workers, включая импортированные из CSV. Общий перечень объединяет оба источника; совпадения не увеличивают итоговое количество.</p>
|
||||
<p>В CSV нет типа материала, точной даты и подтверждения К1 или RSCI. Счётчик CSV предварительный: для пограничных лет нужно проверить дату, для материалов без типа — подтвердить, что это статьи. Материалы, для которых в БД указан другой тип, в этот счётчик не входят. Отсутствие отметки означает недостаток данных. Наличие публикаций не подтверждает соответствие всем требованиям совета.</p>
|
||||
</details>
|
||||
|
||||
<form class="council__search" method="get" action="/admin/council" role="search">
|
||||
<label class="directory__field" for="council-search">Поиск по ФИО
|
||||
<input class="directory__input" id="council-search" name="q" type="search" value="{{ q }}" placeholder="Фамилия или полное имя">
|
||||
</label>
|
||||
<label class="directory__field">Сортировать по
|
||||
<select class="directory__input" name="sort">
|
||||
{% for value, label in [('full_name', 'ФИО'), ('csv_count', 'CSV по критериям'), ('db_count', 'Публикации в БД'), ('count', 'Всего без дублей')] %}
|
||||
<option value="{{ value }}"{% if sort == value %} selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="directory__field">Направление
|
||||
<select class="directory__input" name="direction">
|
||||
<option value="asc"{% if direction == 'asc' %} selected{% endif %}>По возрастанию</option>
|
||||
<option value="desc"{% if direction == 'desc' %} selected{% endif %}>По убыванию</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="button" type="submit">Применить</button>
|
||||
<button class="button button--secondary" type="submit" formaction="/admin/council/export.xlsx">Скачать XLSX</button>
|
||||
{% if q %}<a class="admin__link" href="?sort={{ sort }}&direction={{ direction }}">Сбросить поиск</a>{% endif %}
|
||||
</form>
|
||||
<div class="council__list-heading">
|
||||
<h3>Сотрудники</h3>
|
||||
<p role="status">{% if matched %}Показаны {{ offset + 1 }}–{{ [offset + limit, matched]|min }} из {{ matched }}{% else %}Найдено: 0{% endif %}{% if q %} · Всего в списке: {{ total }}{% endif %}</p>
|
||||
</div>
|
||||
{% if candidates %}
|
||||
<p class="council__hint">Откройте сотрудника, чтобы увидеть публикации, источники и основания. XLSX содержит всех найденных сотрудников и их публикации, включая другие страницы списка.</p>
|
||||
<div class="council__list">
|
||||
{% for candidate in candidates %}
|
||||
<details class="council__candidate">
|
||||
<summary class="council__candidate-summary">
|
||||
<span class="council__candidate-identity"><strong>{{ candidate.name }}</strong><small>{{ candidate.degree }}{% if candidate.dismissed %} · Уволен{% endif %}</small></span>
|
||||
<span class="council__counts">
|
||||
<span class="council__candidate-count"><strong>{{ candidate.csv_count }}</strong><small>CSV по критериям</small></span>
|
||||
<span class="council__candidate-count"><strong>{{ candidate.db_count }}</strong><small>В БД</small></span>
|
||||
<span class="council__candidate-count"><strong>{{ candidate.count }}</strong><small>Всего без дублей</small></span>
|
||||
</span>
|
||||
<span class="council__candidate-cue" aria-hidden="true"></span>
|
||||
</summary>
|
||||
<div class="council__candidate-body">
|
||||
{% if candidate.profile_url %}<a class="admin__link" href="{{ candidate.profile_url }}" target="_blank" rel="noopener noreferrer">Профиль на сайте ВШЭ</a>{% endif %}
|
||||
{% if candidate.ambiguous %}<p class="council__hint">В базе есть сотрудники с одинаковым ФИО. Публикации из файла не присвоены автоматически; показаны записи, связанные с этим сотрудником в БД.</p>{% endif %}
|
||||
<ol class="council__articles">
|
||||
{% for article in candidate.articles %}
|
||||
<li class="council__article">
|
||||
<div class="council__article-heading">
|
||||
<strong>{{ article.title }}</strong>
|
||||
<span>{{ article.year or 'Год не указан' }}</span>
|
||||
</div>
|
||||
<div class="council__article-meta">
|
||||
{% for origin in article.origins %}<span class="council__source">{{ origin }}</span>{% endfor %}
|
||||
<span>{{ article.type_label }}</span>
|
||||
{% if article.doi_url %}<a class="admin__link" href="{{ article.doi_url }}" target="_blank" rel="noopener noreferrer">DOI</a>{% endif %}
|
||||
{% if article.url %}<a class="admin__link" href="{{ article.url }}" target="_blank" rel="noopener noreferrer">Публикация на сайте</a>{% endif %}
|
||||
{% if article.rows %}<span>Строки CSV: {{ article.rows|join(', ') }}</span>{% endif %}
|
||||
</div>
|
||||
<ul class="council__evidence" aria-label="Критерии и подтверждения">
|
||||
{% for criterion in article.criteria %}<li>{{ criterion }}</li>{% endfor %}
|
||||
</ul>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</div>
|
||||
</details>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if matched > limit %}
|
||||
<nav class="directory__pagination" aria-label="Страницы сотрудников">
|
||||
{% if offset > 0 %}<a class="admin__link" href="?q={{ q|urlencode }}&sort={{ sort }}&direction={{ direction }}&offset={{ offset - limit }}">Назад</a>{% endif %}
|
||||
<span class="directory__page">{{ offset // limit + 1 }} / {{ (matched + limit - 1) // limit }}</span>
|
||||
{% if offset + limit < matched %}<a class="admin__link" href="?q={{ q|urlencode }}&sort={{ sort }}&direction={{ direction }}&offset={{ offset + limit }}">Далее</a>{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="council__message">
|
||||
<h4>{% if q %}Сотрудники не найдены{% else %}Сотрудники с докторской степенью и публикациями пока не найдены{% endif %}</h4>
|
||||
<p>{% if q %}Попробуйте другую фамилию или сбросьте поиск.{% else %}Для включения в список нужны докторская степень в профиле и хотя бы одна публикация, однозначно связанная с сотрудником.{% endif %}</p>
|
||||
{% if q %}<a class="admin__link" href="?sort={{ sort }}&direction={{ direction }}">Сбросить поиск</a>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -28,15 +28,15 @@
|
||||
</section>
|
||||
<section class="panel progress-panel" data-progress-panel>
|
||||
<div class="progress-panel__header">
|
||||
<h2 class="panel__title">Прогресс парсинга</h2>
|
||||
<div class="progress-panel__actions">
|
||||
<form method="post" action="/admin/crawl-now">
|
||||
<button class="button" type="submit">Запустить парсинг</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/dismissed/refresh">
|
||||
<button class="button button--secondary" type="submit">Проверить уволенных</button>
|
||||
</form>
|
||||
</div>
|
||||
<h2 class="panel__title">Прогресс парсинга</h2>
|
||||
<div class="progress-panel__actions">
|
||||
<form method="post" action="/admin/crawl-now">
|
||||
<button class="button" type="submit">Запустить парсинг</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/dismissed/refresh">
|
||||
<button class="button button--secondary" type="submit">Проверить уволенных</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% set run = counts.current_running_run or latest_run %}
|
||||
<div class="progress-panel__body" data-progress-body>
|
||||
@@ -46,10 +46,11 @@
|
||||
<span>без изменений: <span data-progress-skipped>{{ run.skipped_count if run else 0 }}</span></span>
|
||||
<span>ошибок: <span data-progress-errors>{{ run.error_count if run else 0 }}</span></span>
|
||||
</div>
|
||||
<div class="progress-bar" aria-label="Parsing progress">
|
||||
<div class="progress-bar" role="progressbar" aria-label="Прогресс парсинга" aria-valuemin="0" aria-valuemax="100" aria-valuenow="{{ run.progress_percent if run else 0 }}" aria-valuetext="{{ run.progress_percent if run else 0 }}%">
|
||||
<div class="progress-bar__fill" data-progress-fill style="width: {{ run.progress_percent if run else 0 }}%"></div>
|
||||
</div>
|
||||
<div class="progress-panel__percent"><span data-progress-percent>{{ run.progress_percent if run else 0 }}</span>%</div>
|
||||
<p class="progress-panel__error" data-progress-error role="status" hidden>Не удалось обновить прогресс. <button class="button button--ghost button--compact" type="button" data-progress-retry>Повторить</button></p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
@@ -58,7 +59,7 @@
|
||||
<thead><tr><th class="table__head">ID</th><th class="table__head">Статус</th><th class="table__head">Обработано</th><th class="table__head">Без изменений</th><th class="table__head">Ошибки</th><th class="table__head">Старт</th></tr></thead>
|
||||
<tbody>
|
||||
{% for run in runs %}
|
||||
<tr class="table__row" onclick="window.location.href='/admin/runs/{{ run.id }}'" onkeydown="if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); window.location.href='/admin/runs/{{ run.id }}'; }" role="link" tabindex="0"><td class="table__cell">{{ run.id }}</td><td class="table__cell">{{ run.status_display }}</td><td class="table__cell">{{ run.parsed_count }}</td><td class="table__cell">{{ run.skipped_count }}</td><td class="table__cell">{{ run.error_count }}</td><td class="table__cell">{{ run.started_display }}</td></tr>
|
||||
<tr class="table__row"><td class="table__cell"><a class="admin__link" href="/admin/runs/{{ run.id }}">{{ run.id }}</a></td><td class="table__cell">{{ run.status_display }}</td><td class="table__cell">{{ run.parsed_count }}</td><td class="table__cell">{{ run.skipped_count }}</td><td class="table__cell">{{ run.error_count }}</td><td class="table__cell">{{ run.started_display }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -5,49 +5,70 @@
|
||||
<div class="directory__header">
|
||||
<div>
|
||||
<h2 class="directory__title">Сотрудники</h2>
|
||||
<p class="directory__summary">Найдено: {{ page.total }}</p>
|
||||
{% set range_start = page.offset + 1 if page.total else 0 %}
|
||||
{% set range_end = [page.offset + page.employees|length, page.total]|min %}
|
||||
<p class="directory__summary" role="status">Показаны {{ range_start }}–{{ range_end }} из {{ page.total }}</p>
|
||||
</div>
|
||||
<button class="button" type="button" data-columns-open>Колонки</button>
|
||||
<button class="button" type="button" data-columns-open aria-controls="columns-dialog" aria-expanded="false">Настроить колонки</button>
|
||||
</div>
|
||||
|
||||
<form class="directory__filters" method="get" action="/admin/directory">
|
||||
<input class="directory__input" name="q" value="{{ filters.q }}" placeholder="ФИО или ссылка">
|
||||
<select class="directory__input" name="status">
|
||||
<fieldset class="directory__filter-group directory__filter-group--search">
|
||||
<legend class="directory__filter-legend">Поиск</legend>
|
||||
<label class="directory__field">ФИО или ссылка<input class="directory__input" name="q" value="{{ filters.q }}" placeholder="Например, Иванов или hse.ru"></label>
|
||||
</fieldset>
|
||||
<fieldset class="directory__filter-group">
|
||||
<legend class="directory__filter-legend">Статус и данные</legend>
|
||||
<label class="directory__field">Статус<select class="directory__input" name="status">
|
||||
<option value="" {% if not filters.status %}selected{% endif %}>Все статусы</option>
|
||||
<option value="active" {% if filters.status == "active" %}selected{% endif %}>Работает</option>
|
||||
<option value="verification_required" {% if filters.status == "verification_required" %}selected{% endif %}>Требует проверки</option>
|
||||
<option value="dismissed" {% if filters.status == "dismissed" %}selected{% endif %}>Уволен</option>
|
||||
</select>
|
||||
<select class="directory__input" name="has_email">
|
||||
</select></label>
|
||||
<label class="directory__field">Email<select class="directory__input" name="has_email">
|
||||
<option value="" {% if not filters.has_email %}selected{% endif %}>Любой email</option>
|
||||
<option value="true" {% if filters.has_email == "true" %}selected{% endif %}>Есть email</option>
|
||||
<option value="false" {% if filters.has_email == "false" %}selected{% endif %}>Нет email</option>
|
||||
</select>
|
||||
<select class="directory__input" name="has_academic_degree" aria-label="Учёная степень">
|
||||
</select></label>
|
||||
<label class="directory__field">Учёная степень<select class="directory__input" name="has_academic_degree">
|
||||
<option value="" {% if not filters.has_academic_degree %}selected{% endif %}>Любая учёная степень</option>
|
||||
<option value="true" {% if filters.has_academic_degree == "true" %}selected{% endif %}>Есть учёная степень</option>
|
||||
<option value="false" {% if filters.has_academic_degree == "false" %}selected{% endif %}>Нет учёной степени</option>
|
||||
</select>
|
||||
<input class="directory__input" type="date" name="started_from" value="{{ filters.started_from }}" aria-label="Впервые найден с">
|
||||
<input class="directory__input" type="date" name="started_to" value="{{ filters.started_to }}" aria-label="Впервые найден по">
|
||||
<select class="directory__input" name="sort">
|
||||
</select></label>
|
||||
</fieldset>
|
||||
<fieldset class="directory__filter-group">
|
||||
<legend class="directory__filter-legend">Период обнаружения</legend>
|
||||
<label class="directory__field">От<input class="directory__input" type="date" name="started_from" value="{{ filters.started_from }}"></label>
|
||||
<label class="directory__field">До<input class="directory__input" type="date" name="started_to" value="{{ filters.started_to }}"></label>
|
||||
</fieldset>
|
||||
<fieldset class="directory__filter-group">
|
||||
<legend class="directory__filter-legend">Сортировка и страница</legend>
|
||||
<label class="directory__field">Сортировать по<select class="directory__input" name="sort">
|
||||
{% for value, label in [("full_name", "ФИО"), ("status", "Статус"), ("hse_start_year", "Год начала"), ("first_seen_at", "Впервые найден"), ("last_seen_at", "Последний раз найден"), ("dismissed_at", "Дата увольнения")] %}
|
||||
<option value="{{ value }}" {% if filters.sort == value %}selected{% endif %}>Сортировка: {{ label }}</option>
|
||||
<option value="{{ value }}" {% if filters.sort == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select class="directory__input" name="direction">
|
||||
</select></label>
|
||||
<label class="directory__field">Направление<select class="directory__input" name="direction">
|
||||
<option value="asc" {% if filters.direction == "asc" %}selected{% endif %}>По возрастанию</option>
|
||||
<option value="desc" {% if filters.direction == "desc" %}selected{% endif %}>По убыванию</option>
|
||||
</select>
|
||||
<select class="directory__input" name="limit" onchange="this.form.offset.value = 0; this.form.submit()">
|
||||
</select></label>
|
||||
<label class="directory__field">Сотрудников на странице<select class="directory__input" name="limit">
|
||||
{% for value in [25, 50, 100] %}
|
||||
<option value="{{ value }}" {% if filters.limit == value %}selected{% endif %}>На странице: {{ value }}</option>
|
||||
<option value="{{ value }}" {% if filters.limit == value %}selected{% endif %}>{{ value }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<input type="hidden" name="offset" value="{{ filters.offset }}">
|
||||
<button class="button" type="submit">Применить</button>
|
||||
</select></label>
|
||||
</fieldset>
|
||||
<div class="directory__filter-actions">
|
||||
<button class="button" type="submit">Применить фильтры</button>
|
||||
<a class="button button--ghost" href="/admin/directory">Сбросить</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<details class="directory__help">
|
||||
<summary>Что означают даты и статусы</summary>
|
||||
<p>«Год начала» — начало работы в ВШЭ. «Впервые найден» и «Последний раз найден» — даты обнаружения сотрудника в списке источника, а не даты приёма и увольнения. «Требует проверки» означает, что профиль недоступен и увольнение ещё не подтверждено.</p>
|
||||
</details>
|
||||
|
||||
<div class="directory__table-wrap">
|
||||
<table class="directory-table" data-directory-table>
|
||||
<thead>
|
||||
@@ -71,9 +92,9 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for employee in page.employees %}
|
||||
<tr class="directory-table__row" data-row-href="/admin/employees/{{ employee.id }}">
|
||||
<td class="directory-table__cell" data-column="full_name">{{ employee.full_name or "Без имени" }}</td>
|
||||
<td class="directory-table__cell" data-column="status"><span class="badge {% if employee.status == "dismissed" %}badge--dismissed{% endif %}">{{ employee.status_display }}</span></td>
|
||||
<tr class="directory-table__row">
|
||||
<td class="directory-table__cell" data-column="full_name"><a class="admin__link" href="/admin/employees/{{ employee.id }}">{{ employee.full_name or "Без имени" }}</a></td>
|
||||
<td class="directory-table__cell" data-column="status"><span class="badge {% if employee.status == "dismissed" %}badge--dismissed{% elif employee.status == "verification_required" %}badge--verification{% endif %}">{{ employee.status_display }}</span></td>
|
||||
<td class="directory-table__cell" data-column="positions">{{ employee.positions_text }}</td>
|
||||
<td class="directory-table__cell" data-column="hse_start_year">{{ employee.hse_start_year or "" }}</td>
|
||||
<td class="directory-table__cell" data-column="email">{{ employee.email_text }}</td>
|
||||
@@ -86,10 +107,18 @@
|
||||
<td class="directory-table__cell" data-column="first_seen_at">{{ employee.first_seen_display }}</td>
|
||||
<td class="directory-table__cell" data-column="last_seen_at">{{ employee.last_seen_display }}</td>
|
||||
<td class="directory-table__cell" data-column="dismissed_at">{{ employee.dismissed_display }}</td>
|
||||
<td class="directory-table__cell" data-column="profile"><a class="admin__link" href="{{ employee.canonical_url }}">Открыть</a></td>
|
||||
<td class="directory-table__cell" data-column="profile"><a class="admin__link" href="{{ employee.canonical_url }}" target="_blank" rel="noopener">Внешний профиль</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td class="directory-table__empty" colspan="15">По этим фильтрам сотрудники не найдены.</td></tr>
|
||||
<tr><td class="directory-table__empty" colspan="15">
|
||||
<strong>Сотрудники не найдены</strong>
|
||||
{% if filters.q or filters.status or filters.has_email or filters.has_academic_degree or filters.started_from or filters.started_to %}
|
||||
<span>Попробуйте изменить запрос или сбросить фильтры.</span>
|
||||
<a class="admin__link" href="/admin/directory">Сбросить фильтры</a>
|
||||
{% else %}
|
||||
<span>Запустите парсинг, чтобы загрузить сотрудников.</span>
|
||||
{% endif %}
|
||||
</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -108,11 +137,10 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="columns-modal" data-columns-modal hidden>
|
||||
<div class="columns-modal__backdrop" data-columns-close></div>
|
||||
<section class="columns-modal__panel" aria-label="Настройка колонок">
|
||||
<dialog class="columns-modal" id="columns-dialog" data-columns-modal aria-labelledby="columns-dialog-title">
|
||||
<section class="columns-modal__panel">
|
||||
<div class="columns-modal__header">
|
||||
<h3 class="columns-modal__title">Отображаемые колонки</h3>
|
||||
<h3 class="columns-modal__title" id="columns-dialog-title">Отображаемые колонки</h3>
|
||||
<button class="button button--ghost" type="button" data-columns-close>Закрыть</button>
|
||||
</div>
|
||||
<div class="columns-modal__grid">
|
||||
@@ -120,8 +148,14 @@
|
||||
<label class="columns-modal__option"><input class="columns-modal__checkbox" type="checkbox" value="{{ key }}" data-column-toggle> {{ label }}</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<fieldset class="columns-modal__presets">
|
||||
<legend class="columns-modal__preset-label">Быстрый набор</legend>
|
||||
<button class="button button--ghost button--compact" type="button" data-columns-preset="review">Проверка</button>
|
||||
<button class="button button--ghost button--compact" type="button" data-columns-preset="contacts">Контакты</button>
|
||||
<button class="button button--ghost button--compact" type="button" data-columns-preset="full">Все поля</button>
|
||||
</fieldset>
|
||||
</section>
|
||||
</div>
|
||||
</dialog>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/admin.js"></script>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Каталог недоступен · MIEM Employees{% endblock %}
|
||||
{% block content %}
|
||||
<section class="panel">
|
||||
<h2 class="panel__title">Не удалось загрузить сотрудников</h2>
|
||||
<p>База данных временно недоступна. Повторите запрос; выбранные фильтры сохранятся.</p>
|
||||
<a class="button" href="{{ request.url.path }}{% if request.url.query %}?{{ request.url.query }}{% endif %}">Повторить загрузку</a>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Вход · MIEM Employees</title>
|
||||
<link rel="stylesheet" href="/static/admin.css">
|
||||
<link rel="stylesheet" href="/static/admin.css?v={{ frontend_version }}">
|
||||
</head>
|
||||
<body class="admin">
|
||||
<main class="admin__main">
|
||||
|
||||
@@ -17,10 +17,11 @@
|
||||
<span>без изменений: <span data-progress-skipped>{{ run.skipped_count }}</span></span>
|
||||
<span>ошибок: <span data-progress-errors>{{ run.error_count }}</span></span>
|
||||
</div>
|
||||
<div class="progress-bar" aria-label="Parsing progress">
|
||||
<div class="progress-bar" role="progressbar" aria-label="Прогресс парсинга" aria-valuemin="0" aria-valuemax="100" aria-valuenow="{{ percent }}" aria-valuetext="{{ percent }}%">
|
||||
<div class="progress-bar__fill" data-progress-fill style="width: {{ percent }}%"></div>
|
||||
</div>
|
||||
<div class="progress-panel__percent"><span data-progress-percent>{{ percent }}</span>%</div>
|
||||
<p class="progress-panel__error" data-progress-error role="status" hidden>Не удалось обновить прогресс. <button class="button button--ghost button--compact" type="button" data-progress-retry>Повторить</button></p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="progress-panel" data-progress-panel>
|
||||
@@ -30,17 +31,18 @@
|
||||
<span>без изменений: <span data-progress-skipped>0</span></span>
|
||||
<span>ошибок: <span data-progress-errors>0</span></span>
|
||||
</div>
|
||||
<div class="progress-bar" aria-label="Parsing progress">
|
||||
<div class="progress-bar" role="progressbar" aria-label="Прогресс парсинга" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-valuetext="0%">
|
||||
<div class="progress-bar__fill" data-progress-fill style="width: 0%"></div>
|
||||
</div>
|
||||
<div class="progress-panel__percent"><span data-progress-percent>0</span>%</div>
|
||||
<p class="progress-panel__error" data-progress-error role="status" hidden>Не удалось обновить прогресс. <button class="button button--ghost button--compact" type="button" data-progress-retry>Повторить</button></p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<table class="table">
|
||||
<thead><tr><th class="table__head">ID</th><th class="table__head">Статус</th><th class="table__head">Найдено</th><th class="table__head">Обработано</th><th class="table__head">Без изменений</th><th class="table__head">Новые</th><th class="table__head">Ошибки</th><th class="table__head">Уволены</th><th class="table__head">Старт</th></tr></thead>
|
||||
<tbody>
|
||||
{% for run in runs %}
|
||||
<tr class="table__row" onclick="window.location.href='/admin/runs/{{ run.id }}'" onkeydown="if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); window.location.href='/admin/runs/{{ run.id }}'; }" role="link" tabindex="0"><td class="table__cell">{{ run.id }}</td><td class="table__cell">{{ run.status_display }}</td><td class="table__cell">{{ run.found_count }}</td><td class="table__cell">{{ run.parsed_count }}</td><td class="table__cell">{{ run.skipped_count }}</td><td class="table__cell">{{ run.new_count }}</td><td class="table__cell">{{ run.error_count }}</td><td class="table__cell">{{ run.dismissed_count }}</td><td class="table__cell">{{ run.started_display }}</td></tr>
|
||||
<tr class="table__row"><td class="table__cell"><a class="admin__link" href="/admin/runs/{{ run.id }}">{{ run.id }}</a></td><td class="table__cell">{{ run.status_display }}</td><td class="table__cell">{{ run.found_count }}</td><td class="table__cell">{{ run.parsed_count }}</td><td class="table__cell">{{ run.skipped_count }}</td><td class="table__cell">{{ run.new_count }}</td><td class="table__cell">{{ run.error_count }}</td><td class="table__cell">{{ run.dismissed_count }}</td><td class="table__cell">{{ run.started_display }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
+3
-3
@@ -1,3 +1,3 @@
|
||||
APP_VERSION = "0.7.5"
|
||||
FRONTEND_VERSION = "0.7.5"
|
||||
BACKEND_VERSION = "0.7.5"
|
||||
APP_VERSION = "0.13.1"
|
||||
FRONTEND_VERSION = "0.13.1"
|
||||
BACKEND_VERSION = "0.13.1"
|
||||
@@ -1,6 +1,7 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-miem_workers}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-miem}
|
||||
@@ -15,6 +16,7 @@ services:
|
||||
|
||||
api:
|
||||
build: .
|
||||
restart: always
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
env_file: .env
|
||||
environment:
|
||||
@@ -25,5 +27,16 @@ services:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
worker:
|
||||
build: .
|
||||
restart: always
|
||||
command: python -m app.worker
|
||||
env_file: .env
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-miem}:${POSTGRES_PASSWORD:-miem_password}@postgres:5432/${POSTGRES_DB:-miem_workers}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
@@ -0,0 +1,99 @@
|
||||
# Проверка импорта на изолированном PostgreSQL
|
||||
|
||||
Запускается средствами проекта: pytest, SQLAlchemy, psycopg и отдельный Docker
|
||||
Compose. Не использует production Compose, `.env`, SSH или данные сотрудников.
|
||||
PostgreSQL 16 и runner работают в отдельной `internal`-сети без опубликованных
|
||||
портов. Данные PostgreSQL находятся в tmpfs, без постоянных volumes. Базы:
|
||||
`miem_import_test` и `miem_crawl_test`. Все сотрудники, статьи и credentials
|
||||
синтетические; тестовые пароли непригодны для реальных систем.
|
||||
|
||||
Dockerfile копирует только нужный код и requirements. Его отдельный dockerignore
|
||||
исключает `.git`, `.env`, файлы ключей/credentials, CSV владельца, данные
|
||||
`app/data` и остальные файлы workspace. Runtime не получает переменные хоста.
|
||||
CLI subprocess дополнительно получает только явно заданные тестовые libpq
|
||||
параметры; `.pgpass`/service-файлы отключены. Не изменяйте адрес `postgres-test`
|
||||
или имена тестовых БД для подключения к существующему серверу.
|
||||
|
||||
Синтетическая роль `import_test` создаётся только init-скриптом одноразового
|
||||
тестового кластера. Fixture выдаёт ей документированные column grants и USAGE
|
||||
sequence. Проверяется отсутствие CREATE схемы, INSERT сотрудников, DELETE
|
||||
публикаций и sequence UPDATE. Это не создание временного доступа к рабочей БД.
|
||||
Таблицы создаются моделью приложения и очищаются только в тестовых БД.
|
||||
|
||||
HTTP заблокирован test fixture; входные профили полного обхода подставляются
|
||||
локально. Это проверяет настоящий SQL, транзакции, savepoint и сериализацию JSON,
|
||||
но не парсинг сайта ВШЭ. Fixtures SQLite и подмена psycopg для интеграционных
|
||||
тестов не используются.
|
||||
|
||||
## Что проверяется
|
||||
|
||||
1. Dry-run: все строки всех таблиц и состояния sequences совпадают до/после.
|
||||
2. Apply: одна вставка, одно обновление, пропуск отсутствующего сотрудника.
|
||||
3. Повторный импорт: ноль INSERT/UPDATE, неизменные таблицы/sequences.
|
||||
4. CHECK constraint тестовой БД вызывает ошибку третьего действия после UPDATE
|
||||
и успешного INSERT; все строки возвращаются в исходное состояние. Sequence
|
||||
может продвинуться при rollback — это ожидаемое поведение PostgreSQL.
|
||||
5. Обновление только года без raw_data сохраняет существующий JSONB.
|
||||
6. Поздние строки CSV дополняют метаданные; первое непустое значение сохраняется
|
||||
при конфликте, отчёт содержит названия конфликтующих полей. Повтор идемпотентен.
|
||||
7. Два последовательных crawl сохраняют CSV-статью, отсутствующую у ВШЭ;
|
||||
появившаяся статья получает HSE ID без второй записи и потери метаданных.
|
||||
8. Полный `run_crawl` обрабатывает двух сотрудников. Два HSE ID на одну CSV-запись
|
||||
откатывают savepoint первого, сохраняя JSONB, авторов, ID, хеш и updated_at;
|
||||
публикация второго сохраняется. Проверяются обе очередности ID и уже
|
||||
установленная привязка; неоднозначность отражается в CrawlError.
|
||||
|
||||
Всего 10 PostgreSQL-тестов (восьмой сценарий параметризован). В обычном
|
||||
`python -m pytest -q` они пропускаются. Opt-in `MIEM_POSTGRES_INTEGRATION=1`
|
||||
разрешён только с точным тестовым DATABASE_URL Compose-проекта.
|
||||
|
||||
## Команды владельцу
|
||||
|
||||
Выполняйте на локальной машине с Docker Engine/Compose v2 из корня репозитория
|
||||
после независимого ревью. Команды явно выбирают локальный Unix socket и очищают
|
||||
наследуемое окружение. Для Docker Desktop/Windows используйте Linux/WSL с
|
||||
доступным локальным `/var/run/docker.sock`. Для отдельного стенда на VPS используйте
|
||||
[отдельную инструкцию](postgresql-import-vps.md) и `compose.vps.yml`: этот локальный
|
||||
Compose не предназначен для запуска на сервере с работающими сервисами.
|
||||
Первый шаг:
|
||||
|
||||
```sh
|
||||
env -i PATH="$PATH" docker --host unix:///var/run/docker.sock version
|
||||
```
|
||||
|
||||
Ожидается exit code 0 и секции `Client` и `Server`. `command not found`,
|
||||
`permission denied` или только Client с ошибкой подключения означает, что
|
||||
до запуска этого варианта нужен локальный Docker. При его отсутствии сначала
|
||||
выполните только read-only диагностику из инструкции VPS.
|
||||
|
||||
Дальнейшие точные команды сохранены здесь для воспроизводимого запуска:
|
||||
|
||||
```sh
|
||||
env -i PATH="$PATH" docker --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration -f tests/postgres/compose.yml config --quiet
|
||||
env -i PATH="$PATH" docker --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration -f tests/postgres/compose.yml build tests
|
||||
env -i PATH="$PATH" docker --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration -f tests/postgres/compose.yml up --abort-on-container-exit --exit-code-from tests
|
||||
```
|
||||
|
||||
Первые две команды проверяют Compose и собирают runner с уже используемыми
|
||||
зависимостями. Сборка/скачивание образов требуют registry/PyPI, но тестовая
|
||||
runtime-сеть не имеет внешнего доступа и запросов к ВШЭ нет. При успехе третьей
|
||||
команды ожидаются `10 passed` и exit code 0. Фактическую совместимость образа,
|
||||
column grants и PostgreSQL можно считать подтверждённой **только после такого
|
||||
выполнения**, не по unit-тестам или статическому чтению файлов.
|
||||
|
||||
После сохранения вывода тестов удалите именно этот тестовый проект:
|
||||
|
||||
```sh
|
||||
env -i PATH="$PATH" docker --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration -f tests/postgres/compose.yml down
|
||||
```
|
||||
|
||||
Это уничтожит только синтетические данные в tmpfs тестового PostgreSQL. Не
|
||||
используйте `docker system prune`, production Compose или удаление рабочих
|
||||
volumes. Перед повторным прогоном выполните этот же `down`, чтобы следующий
|
||||
кластер начинался с чистого init.sql; не запускайте два прогона одновременно.
|
||||
|
||||
## Статус подготовки
|
||||
|
||||
В среде подготовки Docker отсутствует; настоящие PostgreSQL-тесты не запускались.
|
||||
Локально доступны только unit-тесты, проверка Python и статические проверки.
|
||||
Production/VPS и доступ к рабочей БД не изменялись.
|
||||
@@ -0,0 +1,153 @@
|
||||
# PostgreSQL-проверка на отдельном стенде VPS
|
||||
|
||||
Команды выполняет владелец под root. Подготовка не подключается к VPS и не
|
||||
запускает стенд. Не используйте production Compose/`.env`, Docker context,
|
||||
volumes, сети, mounts или production credentials. Никаких production-ролей БД,
|
||||
SSH-ключей и правил firewall эти команды не создают и не изменяют.
|
||||
|
||||
## Сначала только read-only проверка
|
||||
|
||||
Следующий блок не создаёт контейнеров, не скачивает образов и не меняет данные.
|
||||
Docker CLI использует локальный Unix socket и очищенное окружение; конфигурация
|
||||
CLI указывает на отдельный каталог, который здесь не создаётся.
|
||||
|
||||
```sh
|
||||
free -h
|
||||
nproc
|
||||
uptime
|
||||
df -h / /var/lib/docker
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock info --format 'CPUs={{.NCPU}} RAM_bytes={{.MemTotal}} cgroup={{.CgroupVersion}} DockerRootDir={{.DockerRootDir}} warnings={{json .Warnings}}'
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock compose version
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock stats --no-stream --format 'table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.PIDs}}'
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock image inspect postgres:16-alpine python:3.12-slim miem-publication-integration-postgres:pr55 miem-publication-integration-runner:pr55 --format '{{.RepoTags}} id={{.Id}} revision={{index .Config.Labels "org.opencontainers.image.revision"}}'
|
||||
```
|
||||
|
||||
Как читать результат:
|
||||
|
||||
- `free`: нужна колонка **available**, а не только free. Предварительный запас —
|
||||
не менее 2 GiB: стенд ограничен суммарно 1 GiB, ещё минимум 1 GiB остаётся
|
||||
системе/сервисам. Это ориентир, решение зависит от их нагрузки и пиков.
|
||||
- `nproc` и `uptime`: сравните число CPU и load average. Постоянный load на уровне
|
||||
числа CPU или выше — основание отложить тест, а не останавливать сервисы.
|
||||
- `df`: желательно не менее 4 GiB свободно для загрузки образов и запаса работы
|
||||
Docker. Если DockerRootDir отличается от `/var/lib/docker`, отдельно выполните
|
||||
`df -h` для показанного каталога. Не используйте prune для освобождения места.
|
||||
- `stats`: показывает потребление уже работающих контейнеров, но не их пиковые
|
||||
требования. Docker warnings о недоступных memory/swap/cgroup limits нужно
|
||||
устранить до запуска; без действующих лимитов этот вариант не запускать.
|
||||
- `image inspect`: существующие образы показывают tags, ID и revision.
|
||||
`No such image` означает отсутствие образа, **не** разрешение на pull/build.
|
||||
Для запуска нужны два образа `miem-publication-integration-*:pr55` с revision
|
||||
проверенного commit PR #55. Base-образы нужны только для сборки, не для самого
|
||||
запуска уже подготовленного стенда.
|
||||
|
||||
Пришлите только этот диагностический вывод. Не присылайте `docker inspect`
|
||||
production-контейнеров целиком, env, конфигурации подключения или секреты.
|
||||
До оценки ресурсов и завершения независимого review остальные действия ниже
|
||||
не выполнять.
|
||||
|
||||
## Изоляция и лимиты подготовленного стенда
|
||||
|
||||
Используется только `tests/postgres/compose.vps.yml`, не override production или
|
||||
локального Compose. Project name фиксирован:
|
||||
`miem-publication-integration-vps`.
|
||||
|
||||
| Ресурс | Лимит / область |
|
||||
|---|---|
|
||||
| PostgreSQL | Отдельный контейнер `postgres-test`, 0.5 CPU, 512 MiB RAM, без дополнительного swap, 128 процессов |
|
||||
| PostgreSQL настройки | shared_buffers 64 MiB, max_connections 12 |
|
||||
| Test runner | 0.5 CPU, 512 MiB RAM, без дополнительного swap, 128 процессов; UID 65534, read-only rootfs, no-new-privileges, без capabilities |
|
||||
| Данные PostgreSQL | Только named volume `miem-publication-integration-vps_pgdata`; local driver tmpfs до 256 MiB |
|
||||
| Временные файлы runner | tmpfs `/tmp` до 128 MiB; входят в memory limit контейнера |
|
||||
| Сеть | Только `miem-publication-integration-vps_isolated`, internal; опубликованных портов нет |
|
||||
| Логи | json-file, до 5 MiB на контейнер, один файл |
|
||||
|
||||
Volume с tmpfs выбран намеренно: отдельное имя для адресной очистки и предел
|
||||
размера синтетических данных. Данные не сохраняются после остановки/размонтирования.
|
||||
Образы содержат только код и синтетический init.sql. Host bind mounts,
|
||||
Docker socket, production networks и env_file отсутствуют. Docker socket
|
||||
доступен только CLI владельца на хосте, ни одному контейнеру он не передаётся.
|
||||
Нет restart policy. Pull запрещён; ресурсы не создаются автоматически при
|
||||
отсутствии заранее подготовленного образа.
|
||||
|
||||
Лимиты процесса сборки/загрузки образов отличаются от runtime: Compose CPU/RAM
|
||||
limits **не ограничивают Docker build**. Поэтому на VPS с рабочими сервисами
|
||||
не выполняйте незапланированную сборку. Если готовых образов нет, отдельно
|
||||
согласуйте место сборки и окно загрузки после read-only диагностики.
|
||||
|
||||
## Подготовка образов после review (не запуск стенда)
|
||||
|
||||
На отдельной согласованной машине сборки с Docker, из проверенной копии PR #55,
|
||||
укажите полный SHA проверенного commit в переменной `TEST_COMMIT` (не секрет).
|
||||
Сначала `git status --short` должен быть пустым, `git rev-parse HEAD` — совпадать
|
||||
с TEST_COMMIT. Это не команды для немедленного выполнения на VPS.
|
||||
|
||||
```sh
|
||||
git status --short
|
||||
git rev-parse HEAD
|
||||
env -i PATH="$PATH" docker --host unix:///var/run/docker.sock build --label org.opencontainers.image.revision="$TEST_COMMIT" -t miem-publication-integration-runner:pr55 -f tests/postgres/Dockerfile .
|
||||
env -i PATH="$PATH" docker --host unix:///var/run/docker.sock build --label org.opencontainers.image.revision="$TEST_COMMIT" -t miem-publication-integration-postgres:pr55 -f tests/postgres/Dockerfile.postgres tests/postgres
|
||||
env -i PATH="$PATH" docker --host unix:///var/run/docker.sock image save --output miem-publication-integration-images.tar miem-publication-integration-postgres:pr55 miem-publication-integration-runner:pr55
|
||||
sha256sum miem-publication-integration-images.tar tests/postgres/compose.vps.yml
|
||||
```
|
||||
|
||||
Dockerfile-specific ignore-файлы исключают секреты, реальные CSV и остальные
|
||||
файлы workspace из обоих контекстов сборки. Передайте владельцу только архив
|
||||
этих двух образов и `compose.vps.yml` с проверенными SHA256, без полного workspace
|
||||
и `.env`. Транспорт выбирает владелец; инструкция не получает SSH/production
|
||||
credentials. Base-образы могут скачиваться только на согласованной машине сборки.
|
||||
|
||||
На VPS после оценки ресурсов/отдельного разрешения владельца: файлы размещаются
|
||||
в `/root/miem-publication-integration`, Compose переименован в `compose.vps.yml`,
|
||||
архив — в `images.tar`. Проверка checksum должна совпасть с источником.
|
||||
|
||||
```sh
|
||||
install -d -m 700 /root/miem-publication-integration /root/miem-publication-integration/docker-config
|
||||
sha256sum /root/miem-publication-integration/images.tar /root/miem-publication-integration/compose.vps.yml
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock image load --input /root/miem-publication-integration/images.tar
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock image inspect miem-publication-integration-postgres:pr55 miem-publication-integration-runner:pr55 --format '{{.RepoTags}} id={{.Id}} revision={{index .Config.Labels "org.opencontainers.image.revision"}}'
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration-vps -f /root/miem-publication-integration/compose.vps.yml config --quiet
|
||||
```
|
||||
|
||||
Проверьте отсутствие уже существующих ресурсов с этим project name. Если они
|
||||
есть, выясните их назначение; не запускайте второй стенд и не удаляйте чужие
|
||||
ресурсы. Следующие команды только читают labels Docker Compose:
|
||||
|
||||
```sh
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock ps -a --filter label=com.docker.compose.project=miem-publication-integration-vps
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock network ls --filter label=com.docker.compose.project=miem-publication-integration-vps
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock volume ls --filter label=com.docker.compose.project=miem-publication-integration-vps
|
||||
```
|
||||
|
||||
## Запуск и адресная очистка (пока не выполнять)
|
||||
|
||||
Только после согласования владельцем ресурсов и проверенного состава стенда:
|
||||
|
||||
```sh
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration-vps -f /root/miem-publication-integration/compose.vps.yml up --no-build --pull never --abort-on-container-exit --exit-code-from tests
|
||||
```
|
||||
|
||||
Ожидаемый успех — `10 passed`, exit code 0. Сохраните вывод/код выхода и логи:
|
||||
|
||||
```sh
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration-vps -f /root/miem-publication-integration/compose.vps.yml logs --no-color > /root/miem-publication-integration/test.log
|
||||
```
|
||||
|
||||
**Следующая команда удаляет контейнеры, внутреннюю сеть и named volume именно
|
||||
этого теста; все его синтетические данные будут потеряны.** Перед ней сохраните
|
||||
отчёт. Она не затрагивает ресурсы других Compose-проектов:
|
||||
|
||||
```sh
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration-vps -f /root/miem-publication-integration/compose.vps.yml down --volumes
|
||||
```
|
||||
|
||||
Не используйте `--remove-orphans`, `docker system prune`, общие rm/rmi/volume prune
|
||||
или down рабочего проекта. Base-образы и образы теста оставьте: они могут быть
|
||||
общими для других проверок. Архив и журнал также не удаляются этой командой.
|
||||
Для подтверждения очистки повторите три read-only команды с project label:
|
||||
в выводе должны остаться только заголовки.
|
||||
|
||||
## Статус
|
||||
|
||||
VPS-стенд не запускался, Docker/Compose-проверка не выполнялась в среде
|
||||
подготовки из-за отсутствия Docker. PostgreSQL-интеграция ещё не подтверждена.
|
||||
@@ -0,0 +1,312 @@
|
||||
# Разовый импорт публикаций
|
||||
|
||||
Инструмент: `scripts/import_publications.py`. Он не импортирует приложение, не читает
|
||||
`.env`, не вызывает `init_db`, не создаёт таблиц и не удаляет записи. Подключение
|
||||
берётся только из libpq environment/service, настроенных владельцем. Production в
|
||||
рамках подготовки не проверялся и не изменялся.
|
||||
|
||||
Локальные проверки: синтетические сотрудники, повторный запуск, дубликаты,
|
||||
неоднозначности, конфликты DOI/года, сохранение заполненных значений и
|
||||
транзакционный путь на тестовом соединении (read-only / rollback при второй
|
||||
ошибочной вставке / успешный commit). Прогон на настоящем PostgreSQL и проверка
|
||||
grants не выполнялись; первый owner-run dry-run с read-only ролью обязателен.
|
||||
|
||||
## Вход и сопоставление
|
||||
|
||||
Файл владельца `Статьи.csv`: UTF-8 с возможным BOM, разделитель `;`, 1797 записей,
|
||||
SHA256 `1f236be56d99677b1403df29a4c1d3a7e6d10cb90bd355d678201a027921b0ce`.
|
||||
Обязательные колонки: `Название публикации`, `Год`, `DOI`, `Авторы НИУ ВШЭ`.
|
||||
Номер строки в отчёте — номер CSV-записи с заголовком, а не физической строки.
|
||||
|
||||
Сотрудник сопоставляется по полному ФИО: Unicode NFKC, регистр, пробелы и ё/е.
|
||||
Учитываются все существующие статусы; новые сотрудники не создаются. Ноль
|
||||
совпадений → `employee_not_found`, несколько → `ambiguous_employee`. Инициалы,
|
||||
переводы, перестановки слов и приблизительные совпадения не используются.
|
||||
|
||||
Публикация сопоставляется **внутри конкретного сотрудника** по нормализованному DOI
|
||||
или полному названию и году. Отсутствующий год БД допускает заполнение при
|
||||
совпадении названия. Если эти способы указывают на разные записи, импорт
|
||||
пропускается (`ambiguous_publication`). Разные заполненные DOI или годы —
|
||||
`doi_conflict` / `year_conflict`. Существующие дубликаты не объединяются и не
|
||||
удаляются. Одна статья у разных сотрудников — предусмотренные моделью авторские
|
||||
связи, а не дубли внутри карточки.
|
||||
|
||||
Повтор CSV внутри сотрудника не создаёт записи. Каждая поздняя строка дополняет
|
||||
ещё пустые метаданные; первый непустой источник имеет приоритет, в том числе
|
||||
ранее сохранённое значение БД. Повтор без изменений — `csv_duplicate`, дополнение
|
||||
запланированной вставки — `merged_csv_duplicate`. При конфликте непустых
|
||||
метаданных первое значение сохраняется; остальные пустые поля всё равно
|
||||
дополняются. Отчёт содержит `metadata_conflict_first_value_kept`, список
|
||||
`conflict_fields` (только названия полей, без значений) и сводный счётчик
|
||||
`metadata_conflicts`. Конфликт DOI/года идентификации по-прежнему пропускает
|
||||
всю связь. Если строка без DOI была запланирована первой, следующая строка с
|
||||
DOI может дополнить ту же вставку. При повторном запуске уже заполненные записи дают
|
||||
`already_present`. Хеш новой записи детерминирован по сотруднику и DOI либо
|
||||
названию/году. `source_hash` существующих записей не меняется.
|
||||
|
||||
Пустые ячейки ничего не стирают. Пустые название/авторы, некорректный год
|
||||
(допустимо 1000–9999) или DOI пропускаются как `invalid_fields`. Повреждённая
|
||||
структура CSV прерывает весь запуск до подключения. Несовместимый JSON БД
|
||||
пропускается (`unsupported_raw_data`, `unsupported_csv_metadata`).
|
||||
|
||||
## Таблицы, столбцы и права
|
||||
|
||||
| Объект | Операция / столбцы |
|
||||
|---|---|
|
||||
| `public.employees` | Только SELECT: `id`, `full_name` |
|
||||
| `public.employee_publications` | SELECT: `id`, `employee_id`, `title`, `year`, `doi_url`, `raw_data` |
|
||||
| Новая публикация | INSERT: `employee_id`, `title`, `year`, `doi_url` при наличии, `raw_data`, `source_hash`, `created_at`, `updated_at` |
|
||||
| Существующая публикация | UPDATE: только пустые `title`, `year`, `doi_url`; `raw_data`; служебный `updated_at` |
|
||||
| Sequence идентификатора публикации | USAGE для `nextval`; имя определяется `pg_get_serial_sequence`, обычно `public.employee_publications_id_seq` |
|
||||
|
||||
Остальные таблицы и sequences не затрагиваются. В `raw_data` сохраняются прежние
|
||||
ключи; добавляется/дополняется `csv_enrichment` только отсутствующими или пустыми
|
||||
значениями. Уже заполненные CSV-метаданные не заменяются. Поля `authors`,
|
||||
`publication_id`, тип материала, язык, аннотация, ссылки HSE и статус не меняются.
|
||||
CSV содержит **только авторов НИУ ВШЭ**, поэтому он не считается полным составом
|
||||
авторов и не заменяет `authors`. Идентификаторы Scopus/WoS, квартиль и остальные
|
||||
непустые исходные ячейки сохраняются в `raw_data.csv_enrichment` без интерпретации.
|
||||
Карточки `employees.current_data` и snapshots не изменяются; отчёт и данные
|
||||
таблицы не означают обновления всех представлений интерфейса.
|
||||
|
||||
Минимальные собственные grants роли: CONNECT к БД, USAGE схемы public,
|
||||
перечисленные SELECT/INSERT/UPDATE по столбцам, USAGE найденной sequence.
|
||||
Не нужны DELETE, TRUNCATE, CREATE, ALTER, ownership, superuser, доступ к прочим
|
||||
таблицам и sequence UPDATE/setval. Роль новая, без членства в других ролях.
|
||||
Права PUBLIC всё равно наследуются: владелец должен отдельно проверить их;
|
||||
этот набор команд не меняет общие права PUBLIC и не гарантирует изоляцию при
|
||||
уже существующих широких общих grants. В частности TEMP может быть выдан PUBLIC.
|
||||
|
||||
## Применение и ограничения
|
||||
|
||||
Без `--apply` используется PostgreSQL `SERIALIZABLE READ ONLY`, без INSERT,
|
||||
UPDATE и nextval, с rollback. С `--apply` весь план применяется одной транзакцией
|
||||
SERIALIZABLE. Advisory lock предотвращает одновременное применение этим
|
||||
импортером. Таймаут запроса — 60 секунд, ожидания блокировок — 5 секунд.
|
||||
Ошибка записи/конфликт сериализации откатывает все изменения таблиц; автоматических
|
||||
повторов нет. Sequence может получить пропуски при откате — это нормальное
|
||||
поведение PostgreSQL. Изменение входного файла во время запуска прерывает импорт.
|
||||
|
||||
JSON-отчёт содержит количества insert/update/skip, причины и пары
|
||||
`номер CSV-записи — employee_id`, без ФИО, названий статей и секретов. Счётчики
|
||||
относятся к авторским связям, а не уникальным статьям. Консоль выводит только
|
||||
сводку. Отчёт сначала пишется в `.pending` до commit, после успеха переименовывается.
|
||||
Если остался `.pending`, следующий запуск блокируется: сначала проверьте исход
|
||||
транзакции. Отчёт `.pending` не доказывает commit; ошибка переименования после
|
||||
commit также возможна. Безопасный повторный dry-run позволяет проверить состояние.
|
||||
При необходимости владелец перемещает `.pending` в архив перед повтором.
|
||||
|
||||
**До применения нужен свежий backup БД.** Во время импорта владелец должен
|
||||
исключить конкурентные записи публикаций на время транзакции, согласовав окно
|
||||
работы; эта подготовка не отключает crawler. Не выполняйте production apply до
|
||||
независимого ревью и развёртывания исправления crawler из этого PR.
|
||||
|
||||
До исправления `_upsert_employee` запускал синхронизацию публикаций даже для
|
||||
неизменённой карточки. При пустом списке публикаций или отсутствии таблицы
|
||||
синхронизация пропускалась. При непустом списке `_sync_employee_publications`
|
||||
удалял все записи сотрудника, чей `source_hash` отсутствовал среди хешей ответа
|
||||
ВШЭ, включая CSV-вставки. Найденные по `publication_id`/хешу записи полностью
|
||||
получали новый `raw_data`, поэтому теряли CSV-метаданные даже без удаления.
|
||||
|
||||
Исправление сохраняет записи с dict `raw_data.csv_enrichment` при очистке
|
||||
устаревших публикаций. При обновлении сохраняется этот ключ и заполненные
|
||||
title/year/doi_url, если ВШЭ вернул пустые значения. Непустые значения ВШЭ
|
||||
продолжают обновлять канонические поля, а исходные CSV-значения остаются в
|
||||
метаданных. Если статья впервые появилась у ВШЭ, crawler сопоставляет её с
|
||||
импортированной записью по DOI или названию/году до новой вставки, сохраняет id
|
||||
и добавляет HSE publication_id. Конфликт/неоднозначность откатывает весь savepoint
|
||||
синхронизации публикаций и регистрирует CrawlError; карточка сотрудника продолжает
|
||||
обрабатываться. Удаление обычных устаревших HSE-записей остаётся прежним. Здесь
|
||||
не приведены команды рестарта или отключения сервисов.
|
||||
|
||||
Уже установленный HSE publication_id CSV-записи нельзя заменить другим HSE ID,
|
||||
даже когда DOI/название/год совпадают. Это проверяется перед изменением полей
|
||||
при любом пути сопоставления (ID, source_hash, CSV-поля). Поэтому две HSE-статьи
|
||||
не могут по очереди перезаписать одну CSV-запись: весь savepoint откатывается
|
||||
независимо от порядка статей. Отсутствующий ID во входе не очищает существующую
|
||||
CSV-привязку. Отчёт обхода (`errors` в деталях запуска) содержит CrawlError с
|
||||
идентификатором записи БД и конфликтующими HSE ID, без ФИО и названий статей.
|
||||
|
||||
## Команды владельцу (не выполнялись)
|
||||
|
||||
Ниже — подготовка разового доступа после ревью. Выполняет владелец на сервере.
|
||||
Рабочая директория — проверенная копия этого PR; файлы программы и CSV должны
|
||||
быть защищены от записи временным SSH-пользователем. Пароли/ключи не включать
|
||||
в репозиторий или чат. Имена контейнеров соответствуют текущему Compose-проекту;
|
||||
владелец проверяет их локально перед выполнением.
|
||||
|
||||
Сначала резервная копия в приватный каталог владельца:
|
||||
|
||||
```sh
|
||||
install -d -m 700 /var/backups/miem-publication-import
|
||||
docker exec miem_workers-postgres-1 pg_dump -U miem -d miem_workers -Fc -f /tmp/publications-before.dump
|
||||
docker cp miem_workers-postgres-1:/tmp/publications-before.dump /var/backups/miem-publication-import/before.dump
|
||||
chmod 600 /var/backups/miem-publication-import/before.dump
|
||||
```
|
||||
|
||||
При повторной операции используйте новый путь backup; не перезаписывайте нужную
|
||||
предыдущую копию. Проверьте exit codes и архив `pg_restore --list` до выдачи write.
|
||||
|
||||
Откройте psql владельцем БД:
|
||||
|
||||
```sh
|
||||
docker exec -it miem_workers-postgres-1 psql -U miem -d miem_workers
|
||||
```
|
||||
|
||||
Выполните SQL (без пароля в SQL/истории):
|
||||
|
||||
```sql
|
||||
\set ON_ERROR_STOP on
|
||||
SELECT format('CREATE ROLE miem_publication_import LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS CONNECTION LIMIT 1 VALID UNTIL %L', CURRENT_TIMESTAMP + interval '24 hours') \gexec
|
||||
\password miem_publication_import
|
||||
GRANT CONNECT ON DATABASE miem_workers TO miem_publication_import;
|
||||
GRANT USAGE ON SCHEMA public TO miem_publication_import;
|
||||
GRANT SELECT (id, full_name) ON public.employees TO miem_publication_import;
|
||||
GRANT SELECT (id, employee_id, title, year, doi_url, raw_data) ON public.employee_publications TO miem_publication_import;
|
||||
-- Сначала выдаются только read-права для dry-run.
|
||||
SELECT pg_get_serial_sequence('public.employee_publications', 'id');
|
||||
SELECT has_schema_privilege('miem_publication_import', 'public', 'CREATE') AS unexpected_create,
|
||||
has_table_privilege('miem_publication_import', 'public.employees', 'INSERT') AS unexpected_employee_insert,
|
||||
has_table_privilege('miem_publication_import', 'public.employee_publications', 'DELETE') AS unexpected_delete;
|
||||
```
|
||||
|
||||
Три проверки должны вернуть false. Не выдавайте write-права при неожиданном
|
||||
доступе. Если sequence не найдена, выясните фактический механизм id перед импортом.
|
||||
Срок роли ограничивает парольную авторизацию, но не заменяет отзыв доступа и
|
||||
завершение открытых сессий.
|
||||
|
||||
После принятого dry-run, непосредственно перед разрешённым apply:
|
||||
|
||||
```sql
|
||||
GRANT INSERT (employee_id, title, year, doi_url, raw_data, source_hash, created_at, updated_at) ON public.employee_publications TO miem_publication_import;
|
||||
GRANT UPDATE (title, year, doi_url, raw_data, updated_at) ON public.employee_publications TO miem_publication_import;
|
||||
SELECT format('GRANT USAGE ON SEQUENCE %s TO miem_publication_import', pg_get_serial_sequence('public.employee_publications', 'id')) \gexec
|
||||
```
|
||||
|
||||
Подготовьте внутри API-контейнера защищённые файлы libpq `pg_service.conf` и
|
||||
`pgpass` из окружения/секрет-хранилища владельца: service
|
||||
`miem_publication_import`, БД `miem_workers`, пользователь
|
||||
`miem_publication_import`. Используйте внутренний PostgreSQL endpoint Compose,
|
||||
порт 5432; не публикуйте порт БД. Поле password хранится только в `pgpass`,
|
||||
доступном UID 65534 с mode 600. Не копируйте `.env` приложения. Этот шаг
|
||||
зависит от секрет-хранилища владельца; универсальной команды получения секрета нет.
|
||||
|
||||
Установите файлы (команды root; каталог контейнера не должен быть volume с
|
||||
непроверенными файлами). `Статьи.csv` расположен рядом с каталогом репозитория:
|
||||
|
||||
```sh
|
||||
docker exec miem_workers-api-1 mkdir -p /opt/miem-import /tmp/miem-import
|
||||
docker cp scripts/import_publications.py miem_workers-api-1:/opt/miem-import/import_publications.py
|
||||
docker cp ../Статьи.csv miem_workers-api-1:/opt/miem-import/articles.csv
|
||||
docker exec miem_workers-api-1 chown -R root:root /opt/miem-import
|
||||
docker exec miem_workers-api-1 chmod 755 /opt/miem-import
|
||||
docker exec miem_workers-api-1 chmod 644 /opt/miem-import/import_publications.py /opt/miem-import/articles.csv
|
||||
docker exec miem_workers-api-1 chown 65534:65534 /opt/miem-import/pg_service.conf /opt/miem-import/pgpass /tmp/miem-import
|
||||
docker exec miem_workers-api-1 chmod 600 /opt/miem-import/pg_service.conf /opt/miem-import/pgpass
|
||||
docker exec miem_workers-api-1 chmod 700 /tmp/miem-import
|
||||
docker exec miem_workers-api-1 sha256sum /opt/miem-import/articles.csv
|
||||
install -m 755 -o root -g root scripts/import-publications-run /usr/local/sbin/miem-publication-import
|
||||
install -m 755 -o root -g root scripts/import-publications-ssh /usr/local/sbin/miem-publication-import-ssh
|
||||
useradd --create-home --shell /bin/sh miem-pub-import
|
||||
install -d -m 700 -o miem-pub-import -g miem-pub-import /home/miem-pub-import/.ssh
|
||||
```
|
||||
|
||||
Проверьте SHA256 CSV по указанному выше значению и script по проверенной копии PR.
|
||||
Секретные файлы должны существовать перед командами chown/chmod. Root-владелец
|
||||
каталога не позволяет UID 65534 заменить программу/CSV; этот UID может писать
|
||||
только отчёт и свои файлы конфигурации. Не предоставляйте SSH-пользователю группу
|
||||
docker или общий sudo. Каталоги и executables выше должны быть root-owned и
|
||||
недоступны для записи этому пользователю. Убедитесь, что абсолютный путь Docker
|
||||
на сервере — `/usr/bin/docker`.
|
||||
|
||||
Создайте `/etc/sudoers.d/miem-publication-import` с mode 440:
|
||||
|
||||
```sudoers
|
||||
miem-pub-import ALL=(root) NOPASSWD: /usr/local/sbin/miem-publication-import dry-run, /usr/local/sbin/miem-publication-import apply
|
||||
```
|
||||
|
||||
```sh
|
||||
visudo -cf /etc/sudoers.d/miem-publication-import
|
||||
```
|
||||
|
||||
В `/home/miem-pub-import/.ssh/authorized_keys` добавьте **только публичный** ключ
|
||||
владельца с ограничениями (замените `<PUBLIC_KEY>` самим публичным ключом):
|
||||
|
||||
```text
|
||||
restrict,command="/usr/local/sbin/miem-publication-import-ssh" <PUBLIC_KEY>
|
||||
```
|
||||
|
||||
```sh
|
||||
chown root:root /home/miem-pub-import/.ssh/authorized_keys
|
||||
chmod 644 /home/miem-pub-import/.ssh/authorized_keys
|
||||
chown root:root /home/miem-pub-import /home/miem-pub-import/.ssh
|
||||
chmod 755 /home/miem-pub-import
|
||||
chmod 755 /home/miem-pub-import/.ssh
|
||||
```
|
||||
|
||||
Root-владение home/.ssh предотвращает замену ограниченного authorized_keys.
|
||||
Убедитесь, что SSH не разрешает этому пользователю другие ключи, пароль,
|
||||
keyboard-interactive, пользовательские environment/rc или общий shell.
|
||||
При необходимости задайте в sshd `Match User miem-pub-import` с
|
||||
`AuthenticationMethods publickey`, `PasswordAuthentication no`,
|
||||
`KbdInteractiveAuthentication no`, `DisableForwarding yes`, `PermitTTY no`,
|
||||
`PermitUserRC no` и указанным ForceCommand. Проверка/reload SSH зависит от
|
||||
конфигурации сервера; перед reload сохраните действующую сессию владельца.
|
||||
|
||||
С клиентской машины параметры хоста/ключа берутся из окружения владельца:
|
||||
|
||||
```sh
|
||||
ssh -i "$IMPORT_SSH_KEY" -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes "miem-pub-import@$PROD_HOST" 'import-publications dry-run'
|
||||
```
|
||||
|
||||
После проверки сводки, выдачи write-grants и отдельного решения владельца:
|
||||
|
||||
```sh
|
||||
ssh -i "$IMPORT_SSH_KEY" -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes "miem-pub-import@$PROD_HOST" 'import-publications apply'
|
||||
ssh -i "$IMPORT_SSH_KEY" -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes "miem-pub-import@$PROD_HOST" 'import-publications dry-run'
|
||||
```
|
||||
|
||||
Повторный dry-run должен показать ноль insert/update. При concurrent changes
|
||||
сводки могут различаться; без production-проверки фактические количества неизвестны.
|
||||
Владелец забирает отчёты без предоставления SCP/general shell временному ключу:
|
||||
|
||||
```sh
|
||||
docker cp miem_workers-api-1:/tmp/miem-import/apply.json ./publication-import-apply.json
|
||||
docker cp miem_workers-api-1:/tmp/miem-import/dry-run.json ./publication-import-dry-run.json
|
||||
```
|
||||
|
||||
## Отзыв доступа
|
||||
|
||||
Владелец прекращает новые SSH-входы: перемещает временный authorized_keys в свой
|
||||
закрытый архив и удаляет только этот sudoers-файл. Не удаляйте данные БД или volumes.
|
||||
|
||||
```sh
|
||||
mv /home/miem-pub-import/.ssh/authorized_keys /var/backups/miem-publication-import/authorized_keys.disabled
|
||||
rm /etc/sudoers.d/miem-publication-import
|
||||
```
|
||||
|
||||
В psql владельцем:
|
||||
|
||||
```sql
|
||||
ALTER ROLE miem_publication_import NOLOGIN;
|
||||
SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE usename = 'miem_publication_import' AND pid <> pg_backend_pid();
|
||||
REVOKE ALL PRIVILEGES ON DATABASE miem_workers FROM miem_publication_import;
|
||||
REVOKE ALL PRIVILEGES ON SCHEMA public FROM miem_publication_import;
|
||||
REVOKE SELECT (id, full_name) ON public.employees FROM miem_publication_import;
|
||||
REVOKE SELECT (id, employee_id, title, year, doi_url, raw_data),
|
||||
INSERT (employee_id, title, year, doi_url, raw_data, source_hash, created_at, updated_at),
|
||||
UPDATE (title, year, doi_url, raw_data, updated_at)
|
||||
ON public.employee_publications FROM miem_publication_import;
|
||||
SELECT format('REVOKE ALL PRIVILEGES ON SEQUENCE %s FROM miem_publication_import', pg_get_serial_sequence('public.employee_publications', 'id')) \gexec
|
||||
DROP ROLE miem_publication_import;
|
||||
```
|
||||
|
||||
После сохранения отчётов и backup удалите только временные секреты подключения:
|
||||
|
||||
```sh
|
||||
docker exec miem_workers-api-1 rm /opt/miem-import/pgpass /opt/miem-import/pg_service.conf
|
||||
```
|
||||
|
||||
Эти действия не используют `DROP OWNED` и не удаляют публикации. SQL роли не
|
||||
является миграцией схемы приложения. Если DROP ROLE не проходит, проверьте
|
||||
оставшиеся grants; не применяйте CASCADE/удаление данных.
|
||||
@@ -0,0 +1,10 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
temporary=$(mktemp)
|
||||
app_dir=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
|
||||
trap 'rm -f -- "$temporary"' EXIT HUP INT TERM
|
||||
|
||||
crontab -l 2>/dev/null | grep -v 'postgres-backup.sh' > "$temporary" || true
|
||||
printf '%s\n' "30 3 * * 0 flock -w 3600 /tmp/miem-postgres-backup.lock $app_dir/infra/postgres-backup.sh || logger -t miem-postgres-backup -- 'backup skipped or failed'" >> "$temporary"
|
||||
crontab "$temporary"
|
||||
@@ -0,0 +1,20 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
umask 077
|
||||
|
||||
app_dir=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
|
||||
backup_dir="$HOME/backups/postgres"
|
||||
backup="$backup_dir/postgres-latest.sql"
|
||||
temporary="$backup.tmp.$$"
|
||||
|
||||
log() { logger -t miem-postgres-backup -- "$*" 2>/dev/null || true; }
|
||||
|
||||
mkdir -p "$backup_dir"
|
||||
trap 'status=$?; rm -f -- "$temporary"; [ "$status" -eq 0 ] || log "backup failed"; exit "$status"' EXIT HUP INT TERM
|
||||
cd "$app_dir"
|
||||
|
||||
docker compose exec -T postgres sh -c 'pg_dumpall -U "$POSTGRES_USER"' > "$temporary"
|
||||
test -s "$temporary"
|
||||
mv "$temporary" "$backup"
|
||||
find "$HOME/backups/miem_workers" -maxdepth 1 -type f -name 'miem_workers-*.sql' -delete 2>/dev/null || true
|
||||
log "created $backup"
|
||||
+3
-2
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "miem-workers"
|
||||
version = "0.7.5"
|
||||
description = "MIEM employees parser, admin API, and MCP server"
|
||||
version = "0.13.1"
|
||||
description = "MIEM employees parser, admin API, and web admin"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"apscheduler>=3.10.4",
|
||||
@@ -12,6 +12,7 @@ dependencies = [
|
||||
"lxml>=5.2.0",
|
||||
"psycopg[binary]>=3.2.0",
|
||||
"pydantic-settings>=2.4.0",
|
||||
"openpyxl>=3.1.5,<4",
|
||||
"python-multipart>=0.0.9",
|
||||
"requests>=2.32.0",
|
||||
"sqlalchemy>=2.0.32",
|
||||
|
||||
@@ -6,6 +6,7 @@ jinja2>=3.1.4
|
||||
lxml>=5.2.0
|
||||
psycopg[binary]>=3.2.0
|
||||
pydantic-settings>=2.4.0
|
||||
openpyxl>=3.1.5,<4
|
||||
python-multipart>=0.0.9
|
||||
requests>=2.32.0
|
||||
sqlalchemy>=2.0.32
|
||||
|
||||
@@ -0,0 +1,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,154 @@
|
||||
"""Offline public council UX check: python tests/browser_council.py.
|
||||
|
||||
Requires Playwright/Chromium; BROWSER_EXECUTABLE may select an installed browser.
|
||||
Synthetic employees and intercepted requests; no production access.
|
||||
"""
|
||||
import os
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from openpyxl import load_workbook
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.db import Base, get_db
|
||||
from app.main import app
|
||||
from app.models import Employee, EmployeePublication
|
||||
from app.services import council
|
||||
|
||||
|
||||
def main():
|
||||
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(engine)
|
||||
factory = sessionmaker(bind=engine)
|
||||
with factory() as db:
|
||||
for number in range(28):
|
||||
person = Employee(profile_key=f"test:{number}", full_name=f"Сотрудник {number:02d}",
|
||||
canonical_url="https://www.hse.ru/staff/test",
|
||||
current_data={"sections": [{"title": "Учёные степени",
|
||||
"items": ["Доктор технических наук"]}]})
|
||||
db.add(person)
|
||||
db.flush()
|
||||
db.add(EmployeePublication(employee_id=person.id, title="Исследование методов передачи данных в фотонных интегральных схемах",
|
||||
year=2024, publication_type="ARTICLE", publication_id=f"{number}",
|
||||
url=f"https://publications.hse.ru/view/{number}", source_hash=f"test:{number}"))
|
||||
if number == 27:
|
||||
db.add(EmployeePublication(employee_id=person.id, title="Дополнительная статья", year=2024,
|
||||
publication_type="ARTICLE", source_hash="test:extra"))
|
||||
for name, degree in [("Без степени", None), ("Кандидат наук", "Кандидат технических наук")]:
|
||||
person = Employee(profile_key=name, full_name=name, canonical_url="https://www.hse.ru/staff/test",
|
||||
current_data={"sections": [{"title": "Учёные степени", "items": [degree] if degree else []}]})
|
||||
db.add(person)
|
||||
db.flush()
|
||||
db.add(EmployeePublication(employee_id=person.id, title="Не включать в совет", year=2024, source_hash=name))
|
||||
db.commit()
|
||||
|
||||
def database():
|
||||
with factory() as db:
|
||||
yield db
|
||||
|
||||
original = council._articles_by_author
|
||||
council._articles_by_author = lambda: {"сотрудник 26": [
|
||||
{"title": f"Публикация из CSV {number}", "year": 2024, "doi": f"10.1234/test-{number}",
|
||||
"types": [], "quartiles": ["Scopus Q1"], "origins": ["Файл CSV"], "rows": [number + 2],
|
||||
"in_csv": True, "in_db": False, "url": None} for number in range(2)]}
|
||||
app.dependency_overrides[get_db] = database
|
||||
client = TestClient(app)
|
||||
output = Path(".impeccable/review")
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def serve(route):
|
||||
url = urlsplit(route.request.url)
|
||||
response = client.get(url.path + ("?" + url.query if url.query else ""))
|
||||
route.fulfill(status=response.status_code, body=response.content,
|
||||
headers={key: value for key, value in response.headers.items()
|
||||
if key in {"content-type", "content-disposition"}})
|
||||
|
||||
try:
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(executable_path=os.getenv("BROWSER_EXECUTABLE"))
|
||||
page = browser.new_page(viewport={"width": 1440, "height": 1000})
|
||||
page.route("**/*", serve)
|
||||
errors = []
|
||||
page.on("pageerror", lambda error: errors.append(str(error)))
|
||||
page.goto("http://miem.test/admin/council")
|
||||
expect(page.get_by_role("status")).to_have_text("Показаны 1–25 из 28")
|
||||
assert "Без степени" not in page.content() and "Кандидат наук" not in page.content()
|
||||
expect(page.get_by_role("link", name="Вход в админку")).to_be_visible()
|
||||
page.get_by_role("link", name="Далее").click()
|
||||
expect(page.get_by_role("status")).to_have_text("Показаны 26–28 из 28")
|
||||
page.get_by_role("link", name="Назад").click()
|
||||
summary = page.locator(".council__candidate-summary").first
|
||||
summary.focus()
|
||||
page.keyboard.press("Enter")
|
||||
expect(page.get_by_text("БД miem-workers · сайт ВШЭ").first).to_be_visible()
|
||||
expect(page.get_by_text("Q1/Q2, К1 и RSCI не подтверждены доступными данными").first).to_be_visible()
|
||||
assert page.locator(".council__candidate").first.evaluate("e => e.open")
|
||||
page.screenshot(path=str(output / "desktop.png"), full_page=True)
|
||||
page.screenshot(path=str(output / "sorting-desktop.png"), full_page=False)
|
||||
for width in (1024, 768, 700, 390, 320):
|
||||
page.set_viewport_size({"width": width, "height": 844})
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= innerWidth"), width
|
||||
assert summary.evaluate("e => e.scrollWidth <= e.clientWidth"), width
|
||||
page.set_viewport_size({"width": 390, "height": 844})
|
||||
page.screenshot(path=str(output / "mobile.png"), full_page=True)
|
||||
page.screenshot(path=str(output / "sorting-mobile.png"), full_page=False)
|
||||
for sort, direction, first in [("full_name", "asc", "Сотрудник 00"),
|
||||
("full_name", "desc", "Сотрудник 27"),
|
||||
("db_count", "desc", "Сотрудник 27"),
|
||||
("count", "desc", "Сотрудник 26"),
|
||||
("csv_count", "desc", "Сотрудник 26")]:
|
||||
page.get_by_label("Сортировать по").select_option(sort)
|
||||
page.get_by_label("Направление").select_option(direction)
|
||||
page.get_by_role("button", name="Применить", exact=True).click()
|
||||
expect(page.locator(".council__candidate-identity strong").first).to_have_text(first)
|
||||
page.get_by_label("Сортировать по").select_option("full_name")
|
||||
page.get_by_label("Направление").select_option("desc")
|
||||
page.get_by_role("button", name="Применить", exact=True).click()
|
||||
page.get_by_role("link", name="Далее").click()
|
||||
expect(page.get_by_label("Сортировать по")).to_have_value("full_name")
|
||||
expect(page.get_by_label("Направление")).to_have_value("desc")
|
||||
expect(page.locator(".council__candidate-identity strong").first).to_have_text("Сотрудник 02")
|
||||
with page.expect_download() as pending_download:
|
||||
page.get_by_role("button", name="Скачать XLSX", exact=True).click()
|
||||
download = pending_download.value
|
||||
assert download.suggested_filename == "council.xlsx"
|
||||
download.save_as(str(output / "council.xlsx"))
|
||||
workbook = load_workbook(output / "council.xlsx")
|
||||
assert workbook["Сотрудники"].max_row == 29
|
||||
assert workbook["Сотрудники"]["B2"].value == "Сотрудник 27"
|
||||
page.get_by_label("Поиск по ФИО").fill("Сотрудник 27")
|
||||
page.get_by_role("button", name="Применить", exact=True).click()
|
||||
expect(page.get_by_role("status")).to_have_text("Показаны 1–1 из 1 · Всего в списке: 28")
|
||||
expect(page.get_by_label("Сортировать по")).to_have_value("full_name")
|
||||
expect(page.get_by_label("Направление")).to_have_value("desc")
|
||||
with page.expect_download() as pending_download:
|
||||
page.get_by_role("button", name="Скачать XLSX", exact=True).click()
|
||||
filtered = load_workbook(BytesIO(Path(pending_download.value.path()).read_bytes()))
|
||||
assert filtered["Сотрудники"].max_row == 2
|
||||
page.get_by_label("Поиск по ФИО").fill("Нет совпадений")
|
||||
page.get_by_role("button", name="Применить", exact=True).click()
|
||||
expect(page.get_by_text("Сотрудники не найдены", exact=True)).to_be_visible()
|
||||
page.get_by_role("link", name="Сбросить поиск").first.click()
|
||||
expect(page.get_by_role("status")).to_have_text("Показаны 1–25 из 28")
|
||||
expect(page.get_by_label("Сортировать по")).to_have_value("full_name")
|
||||
expect(page.get_by_label("Направление")).to_have_value("desc")
|
||||
assert not errors, errors
|
||||
browser.close()
|
||||
finally:
|
||||
council._articles_by_author = original
|
||||
app.dependency_overrides.clear()
|
||||
client.close()
|
||||
engine.dispose()
|
||||
print(f"Council browser checks passed; screenshots: {output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,7 @@
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN python -m pip install --no-cache-dir -r requirements.txt
|
||||
COPY app ./app
|
||||
COPY scripts/import_publications.py ./scripts/import_publications.py
|
||||
COPY tests/conftest.py tests/test_publication_import_postgres.py ./tests/
|
||||
@@ -0,0 +1,18 @@
|
||||
**
|
||||
!requirements.txt
|
||||
!app/
|
||||
!app/**
|
||||
!scripts/
|
||||
!scripts/import_publications.py
|
||||
!tests/
|
||||
!tests/conftest.py
|
||||
!tests/test_publication_import_postgres.py
|
||||
!tests/postgres/
|
||||
!tests/postgres/Dockerfile
|
||||
!tests/postgres/Dockerfile.dockerignore
|
||||
app/data/
|
||||
**/__pycache__/
|
||||
**/.env*
|
||||
**/*credentials*
|
||||
**/*.key
|
||||
**/*.pem
|
||||
@@ -0,0 +1,2 @@
|
||||
FROM postgres:16-alpine
|
||||
COPY init.sql /docker-entrypoint-initdb.d/10-test.sql
|
||||
@@ -0,0 +1,4 @@
|
||||
**
|
||||
!init.sql
|
||||
!Dockerfile.postgres
|
||||
!Dockerfile.postgres.dockerignore
|
||||
@@ -0,0 +1,69 @@
|
||||
name: miem-publication-integration-vps
|
||||
|
||||
services:
|
||||
postgres-test:
|
||||
image: miem-publication-integration-postgres:pr55
|
||||
pull_policy: never
|
||||
environment:
|
||||
POSTGRES_USER: test_admin
|
||||
POSTGRES_PASSWORD: isolated_test_admin
|
||||
POSTGRES_DB: miem_import_test
|
||||
command: ["postgres", "-c", "shared_buffers=64MB", "-c", "max_connections=12"]
|
||||
cpus: 0.5
|
||||
mem_limit: 512m
|
||||
memswap_limit: 512m
|
||||
pids_limit: 128
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD", "pg_isready", "-h", "127.0.0.1", "-U", "test_admin", "-d", "miem_import_test"]
|
||||
interval: 2s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "5m"
|
||||
max-file: "1"
|
||||
networks: [isolated]
|
||||
|
||||
tests:
|
||||
image: miem-publication-integration-runner:pr55
|
||||
pull_policy: never
|
||||
user: "65534:65534"
|
||||
read_only: true
|
||||
cap_drop: [ALL]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
cpus: 0.5
|
||||
mem_limit: 512m
|
||||
memswap_limit: 512m
|
||||
pids_limit: 128
|
||||
tmpfs:
|
||||
- /tmp:size=128m,mode=1777
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg://test_admin:isolated_test_admin@postgres-test:5432/miem_import_test
|
||||
MIEM_POSTGRES_INTEGRATION: "1"
|
||||
PYTHONDONTWRITEBYTECODE: "1"
|
||||
PYTEST_ADDOPTS: "-p no:cacheprovider"
|
||||
command: ["python", "-m", "pytest", "-q", "tests/test_publication_import_postgres.py"]
|
||||
depends_on:
|
||||
postgres-test:
|
||||
condition: service_healthy
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "5m"
|
||||
max-file: "1"
|
||||
networks: [isolated]
|
||||
|
||||
networks:
|
||||
isolated:
|
||||
internal: true
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: tmpfs
|
||||
device: tmpfs
|
||||
o: size=256m
|
||||
@@ -0,0 +1,36 @@
|
||||
name: miem-publication-integration
|
||||
|
||||
services:
|
||||
postgres-test:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: test_admin
|
||||
POSTGRES_PASSWORD: isolated_test_admin
|
||||
POSTGRES_DB: miem_import_test
|
||||
tmpfs:
|
||||
- /var/lib/postgresql/data
|
||||
volumes:
|
||||
- ./init.sql:/docker-entrypoint-initdb.d/10-test.sql:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "pg_isready", "-h", "127.0.0.1", "-U", "test_admin", "-d", "miem_import_test"]
|
||||
interval: 2s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
networks: [isolated]
|
||||
|
||||
tests:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: tests/postgres/Dockerfile
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg://test_admin:isolated_test_admin@postgres-test:5432/miem_import_test
|
||||
MIEM_POSTGRES_INTEGRATION: "1"
|
||||
command: ["python", "-m", "pytest", "-q", "tests/test_publication_import_postgres.py"]
|
||||
depends_on:
|
||||
postgres-test:
|
||||
condition: service_healthy
|
||||
networks: [isolated]
|
||||
|
||||
networks:
|
||||
isolated:
|
||||
internal: true
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Synthetic credentials for this ephemeral, private test cluster only.
|
||||
CREATE DATABASE miem_crawl_test OWNER test_admin;
|
||||
CREATE ROLE import_test LOGIN PASSWORD 'isolated_test_import'
|
||||
NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS
|
||||
CONNECTION LIMIT 1;
|
||||
GRANT CONNECT ON DATABASE miem_import_test, miem_crawl_test TO import_test;
|
||||
@@ -0,0 +1,26 @@
|
||||
from app.services.academic_degrees import academic_degrees
|
||||
|
||||
|
||||
def test_academic_degrees_extracts_all_degree_names_without_surrounding_text():
|
||||
data = {
|
||||
"sections": [
|
||||
{
|
||||
"title": "Образование",
|
||||
"year_entries": [
|
||||
{"text": "2008 — кандидат технических наук, доцент"},
|
||||
{"text": "2018 — Доктор физико-математических наук"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "Учёные степени",
|
||||
"table": {"rows": [{"cells": ["Ph.D.", "Доктор физико-математических наук"]}]},
|
||||
},
|
||||
{"title": "Публикации", "items": ["Доктор медицинских наук выступил автором статьи"]},
|
||||
]
|
||||
}
|
||||
|
||||
assert academic_degrees(data) == [
|
||||
"кандидат технических наук",
|
||||
"Доктор физико-математических наук",
|
||||
"Ph.D.",
|
||||
]
|
||||
@@ -268,6 +268,18 @@ def test_list_employees_page_filters_sorts_and_paginates(db_session):
|
||||
assert page["limit"] == 50
|
||||
|
||||
|
||||
def test_catalog_recovers_from_out_of_range_offset(db_session):
|
||||
db_session.add(Employee(profile_key="staff:page", canonical_url="https://www.hse.ru/staff/page", full_name="Page Person"))
|
||||
db_session.commit()
|
||||
|
||||
page = list_employees_page(db_session, q="Page", offset=100, limit=25)
|
||||
assert (page["offset"], page["page"], page["total"]) == (0, 1, 1)
|
||||
assert len(page["employees"]) == 1
|
||||
empty = list_employees_page(db_session, q="missing", offset=100)
|
||||
assert empty["offset"] == 0
|
||||
assert empty["employees"] == []
|
||||
|
||||
|
||||
def test_stats_payload_uses_latest_run_new_count(db_session):
|
||||
db_session.add(
|
||||
Employee(
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
from pathlib import Path
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
|
||||
def test_base_navigation_is_russian_and_has_no_legacy_employees_link():
|
||||
template = Path("app/templates/base.html").read_text(encoding="utf-8")
|
||||
template = Environment(loader=FileSystemLoader("app/templates")).get_template("base.html").render(
|
||||
request=SimpleNamespace(url=SimpleNamespace(path="/admin"))
|
||||
)
|
||||
|
||||
assert "Обзор" in template
|
||||
assert "Сотрудники" in template
|
||||
@@ -17,11 +22,11 @@ def test_directory_template_is_russian_and_uses_display_dates():
|
||||
template = Path("app/templates/directory.html").read_text(encoding="utf-8")
|
||||
|
||||
assert "Сотрудники" in template
|
||||
assert "Колонки" in template
|
||||
assert "Применить" in template
|
||||
assert "На странице: {{ value }}" in template
|
||||
assert "колонки" in template
|
||||
assert "Применить фильтры" in template
|
||||
assert "Сотрудников на странице" in template
|
||||
assert "{% for value in [25, 50, 100] %}" in template
|
||||
assert "Найдено:" in template
|
||||
assert "Показаны" in template
|
||||
assert "Новости" in template
|
||||
assert "Есть учёная степень" in template
|
||||
assert 'data-column="academic_degree"' in template
|
||||
@@ -29,7 +34,17 @@ def test_directory_template_is_russian_and_uses_display_dates():
|
||||
assert "employee.first_seen_display" in template
|
||||
assert "employee.last_seen_display" in template
|
||||
assert "employee.dismissed_display" in template
|
||||
assert "verification_required" in template
|
||||
assert "verification_required" in template
|
||||
assert '<label class="directory__field">Статус' in template
|
||||
assert 'id="columns-dialog"' in template
|
||||
assert 'aria-controls="columns-dialog"' in template
|
||||
assert 'aria-labelledby="columns-dialog-title"' in template
|
||||
assert "Сбросить фильтры" in template
|
||||
assert 'data-columns-preset="review"' in template
|
||||
assert '<dialog class="columns-modal"' in template
|
||||
assert 'id="columns-dialog"' in template
|
||||
assert 'aria-controls="columns-dialog"' in template
|
||||
assert "Сбросить фильтры" in template
|
||||
assert "Directory" not in template
|
||||
assert "employees found" not in template
|
||||
|
||||
@@ -50,12 +65,9 @@ def test_dashboard_limits_latest_runs_to_five():
|
||||
def test_runs_template_links_to_run_detail():
|
||||
template = Path("app/templates/runs.html").read_text(encoding="utf-8")
|
||||
|
||||
assert 'onclick="window.location.href=\'/admin/runs/{{ run.id }}\'"' in template
|
||||
assert "onkeydown=\"if (event.key === 'Enter' || event.key === ' ')" in template
|
||||
assert 'role="link"' in template
|
||||
assert 'tabindex="0"' in template
|
||||
assert 'data-row-href="/admin/runs/{{ run.id }}"' not in template
|
||||
assert '<a class="admin__link" href="/admin/runs/{{ run.id }}">' not in template
|
||||
assert '<a class="admin__link" href="/admin/runs/{{ run.id }}">' in template
|
||||
assert "onclick=" not in template
|
||||
assert 'role="link"' not in template
|
||||
|
||||
|
||||
def test_run_detail_template_extends_base_and_shows_change_groups():
|
||||
@@ -86,22 +98,36 @@ def test_dashboard_has_dismissed_status_refresh_action():
|
||||
|
||||
assert 'action="/admin/dismissed/refresh"' in template
|
||||
assert "Проверить уволенных" in template
|
||||
|
||||
|
||||
def test_progress_template_has_accessible_progressbar():
|
||||
for name in ("dashboard.html", "runs.html"):
|
||||
template = Path(f"app/templates/{name}").read_text(encoding="utf-8")
|
||||
assert 'role="progressbar"' in template
|
||||
assert 'aria-valuemin="0"' in template
|
||||
assert 'aria-valuemax="100"' in template
|
||||
assert "Прогресс парсинга" in template
|
||||
|
||||
|
||||
def test_dashboard_latest_run_rows_link_to_run_detail():
|
||||
template = Path("app/templates/dashboard.html").read_text(encoding="utf-8")
|
||||
|
||||
assert 'onclick="window.location.href=\'/admin/runs/{{ run.id }}\'"' in template
|
||||
assert "onkeydown=\"if (event.key === 'Enter' || event.key === ' ')" in template
|
||||
assert 'role="link"' in template
|
||||
assert 'tabindex="0"' in template
|
||||
assert 'data-row-href="/admin/runs/{{ run.id }}"' not in template
|
||||
assert '<a class="admin__link" href="/admin/runs/{{ run.id }}">' not in template
|
||||
assert '<a class="admin__link" href="/admin/runs/{{ run.id }}">' in template
|
||||
assert "onclick=" not in template
|
||||
assert 'role="link"' not in template
|
||||
|
||||
|
||||
def test_admin_js_supports_keyboard_activation_for_clickable_rows():
|
||||
source = Path("app/static/admin.js").read_text(encoding="utf-8")
|
||||
|
||||
assert 'addEventListener("keydown"' in source
|
||||
assert '"Enter"' in source
|
||||
assert '" "' in source
|
||||
def test_admin_js_uses_native_dialog_for_column_settings():
|
||||
source = Path("app/static/admin.js").read_text(encoding="utf-8")
|
||||
|
||||
assert "showModal()" in source
|
||||
assert "modal.close()" in source
|
||||
assert 'setAttribute("aria-expanded", "true")' in source
|
||||
assert "data-progress-retry" in source
|
||||
|
||||
|
||||
def test_base_template_has_skip_link_and_current_navigation_state():
|
||||
template = Path("app/templates/base.html").read_text(encoding="utf-8")
|
||||
|
||||
assert 'href="#main-content"' in template
|
||||
assert 'aria-current="page"' in template
|
||||
@@ -0,0 +1,154 @@
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import Base, get_db
|
||||
from app.main import app
|
||||
from app.models import CrawlRun, CrawlRunEmployeeChange, Employee
|
||||
from app.security import SESSION_COOKIE, sign_session
|
||||
|
||||
|
||||
def test_health_returns_versions():
|
||||
response = TestClient(app).get("/api/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["backend_version"] == "0.13.1"
|
||||
|
||||
|
||||
def test_api_employees_and_stats_require_admin_session():
|
||||
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(engine)
|
||||
session_factory = sessionmaker(bind=engine)
|
||||
db = session_factory()
|
||||
employee = Employee(
|
||||
profile_key="staff:alpha",
|
||||
profile_type="staff",
|
||||
profile_id="alpha",
|
||||
canonical_url="https://www.hse.ru/staff/alpha",
|
||||
full_name="Alpha Person",
|
||||
status="active",
|
||||
first_seen_at=datetime.now(timezone.utc),
|
||||
last_seen_at=datetime.now(timezone.utc),
|
||||
current_data={"contacts": {"emails": ["alpha@hse.ru"]}, "sections": []},
|
||||
)
|
||||
run = CrawlRun(source_url="https://miem.hse.ru/persons", status="completed", new_count=1)
|
||||
db.add_all([employee, run])
|
||||
db.commit()
|
||||
db.add(CrawlRunEmployeeChange(
|
||||
crawl_run_id=run.id,
|
||||
employee_id=employee.id,
|
||||
profile_key=employee.profile_key,
|
||||
profile_url=employee.canonical_url,
|
||||
full_name=employee.full_name,
|
||||
change_type="new",
|
||||
profile_available=True,
|
||||
message="added",
|
||||
))
|
||||
db.commit()
|
||||
run_id = run.id
|
||||
db.close()
|
||||
|
||||
settings = Settings(admin_username="admin", admin_password="password", session_secret="session-secret")
|
||||
|
||||
def override_db():
|
||||
session = session_factory()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
app.dependency_overrides[get_settings] = lambda: settings
|
||||
client = TestClient(app)
|
||||
client.cookies.set(SESSION_COOKIE, sign_session("admin", settings))
|
||||
|
||||
employees = client.get("/api/employees", params={"q": "Alpha", "has_email": True})
|
||||
stats = client.get("/api/stats")
|
||||
run_details = client.get(f"/api/crawl-runs/{run_id}")
|
||||
|
||||
assert employees.status_code == 200
|
||||
assert employees.json()["total"] == 1
|
||||
assert stats.status_code == 200
|
||||
assert stats.json()["new_in_last_run"] == 1
|
||||
assert run_details.status_code == 200
|
||||
assert run_details.json()["changes"]["new"][0]["full_name"] == "Alpha Person"
|
||||
directory = client.get("/admin/directory?q=Alpha&offset=100")
|
||||
assert directory.status_code == 200
|
||||
assert "Показаны 1–1 из 1" in directory.text
|
||||
assert 'name="offset"' not in directory.text
|
||||
assert "Alpha Person" in directory.text
|
||||
empty = client.get("/admin/directory?q=missing&offset=100")
|
||||
assert "Показаны 0–0 из 0" in empty.text
|
||||
assert "Сбросить фильтры" in empty.text
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_admin_refresh_employee_route_updates_only_requested_employee(monkeypatch):
|
||||
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(engine)
|
||||
session_factory = sessionmaker(bind=engine)
|
||||
db = session_factory()
|
||||
db.add(Employee(
|
||||
profile_key="org_person:133709486",
|
||||
profile_type="org_person",
|
||||
profile_id="133709486",
|
||||
canonical_url="https://www.hse.ru/org/persons/133709486",
|
||||
full_name="Будков Юрий Алексеевич",
|
||||
status="active",
|
||||
))
|
||||
db.commit()
|
||||
employee_id = db.scalar(select(Employee.id))
|
||||
db.close()
|
||||
|
||||
settings = Settings(admin_username="admin", admin_password="password", session_secret="session-secret")
|
||||
|
||||
def override_db():
|
||||
session = session_factory()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_refresh_employee(db, refreshed_employee, route_settings):
|
||||
calls.append((refreshed_employee.id, route_settings))
|
||||
return SimpleNamespace(status="completed")
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
app.dependency_overrides[get_settings] = lambda: settings
|
||||
monkeypatch.setattr("app.admin.refresh_employee", fake_refresh_employee)
|
||||
client = TestClient(app)
|
||||
client.cookies.set(SESSION_COOKIE, sign_session("admin", settings))
|
||||
|
||||
response = client.post(f"/admin/employees/{employee_id}/refresh", follow_redirects=False)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == f"/admin/employees/{employee_id}?refresh_status=success"
|
||||
assert calls == [(employee_id, settings)]
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_directory_database_error_has_retry_and_preserves_filters(db_session, monkeypatch):
|
||||
settings = Settings(_env_file=None, session_secret="test-session-secret")
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
app.dependency_overrides[get_settings] = lambda: settings
|
||||
def unavailable(*args, **kwargs):
|
||||
raise OperationalError("SELECT", {}, Exception("private database details"))
|
||||
monkeypatch.setattr("app.admin.list_employees_page", unavailable)
|
||||
try:
|
||||
client = TestClient(app)
|
||||
client.cookies.set(SESSION_COOKIE, sign_session(settings.admin_username, settings))
|
||||
response = client.get("/admin/directory?q=Alice")
|
||||
assert response.status_code == 503
|
||||
assert 'href="/admin/directory?q=Alice"' in response.text
|
||||
assert "Повторить загрузку" in response.text
|
||||
assert "private database details" not in response.text
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
@@ -1,532 +0,0 @@
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import Base, get_db
|
||||
from app.main import app
|
||||
from app.models import CrawlRun, CrawlRunEmployeeChange, Employee, EmployeePublication
|
||||
from app.security import SESSION_COOKIE, sign_session
|
||||
|
||||
|
||||
def test_health_returns_versions():
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/api/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["backend_version"] == "0.7.3"
|
||||
|
||||
|
||||
def test_mcp_lists_tools_without_auth_and_ignores_auth_header():
|
||||
engine = create_engine(
|
||||
"sqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
Session = sessionmaker(bind=engine)
|
||||
|
||||
def override_db():
|
||||
session = Session()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
client = TestClient(app)
|
||||
|
||||
without_auth = client.post("/mcp", json={"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}})
|
||||
with_auth = client.post(
|
||||
"/mcp",
|
||||
headers={"Authorization": "Bearer anything"},
|
||||
json={"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}},
|
||||
)
|
||||
|
||||
assert without_auth.status_code == 200
|
||||
assert with_auth.status_code == 200
|
||||
tool_names = {tool["name"] for tool in without_auth.json()["result"]["tools"]}
|
||||
assert "search_employees" in tool_names
|
||||
assert "get_service_info" in tool_names
|
||||
assert "sync_employees" in tool_names
|
||||
assert any(tool["name"] == "get_crawl_run_details" for tool in without_auth.json()["result"]["tools"])
|
||||
assert with_auth.json()["result"]["tools"] == without_auth.json()["result"]["tools"]
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_mcp_search_employees_returns_matching_employee():
|
||||
engine = create_engine(
|
||||
"sqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
Session = sessionmaker(bind=engine)
|
||||
session = Session()
|
||||
session.add(
|
||||
Employee(
|
||||
profile_key="staff:avsergeev",
|
||||
profile_type="staff",
|
||||
profile_id="avsergeev",
|
||||
canonical_url="https://www.hse.ru/staff/avsergeev",
|
||||
full_name="Сергеев Алексей Викторович",
|
||||
status="active",
|
||||
first_seen_at=datetime.now(timezone.utc),
|
||||
last_seen_at=datetime.now(timezone.utc),
|
||||
current_data={"sections": []},
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
session.close()
|
||||
|
||||
def override_db():
|
||||
db = Session()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/mcp",
|
||||
json={
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {"name": "search_employees", "arguments": {"query": "Сергеев"}},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Сергеев Алексей Викторович" in response.json()["result"]["content"][0]["text"]
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_mcp_service_info_returns_tools_and_dataset_hash():
|
||||
engine = create_engine(
|
||||
"sqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
Session = sessionmaker(bind=engine)
|
||||
session = Session()
|
||||
session.add(
|
||||
Employee(
|
||||
profile_key="staff:alpha",
|
||||
profile_type="staff",
|
||||
profile_id="alpha",
|
||||
canonical_url="https://www.hse.ru/staff/alpha",
|
||||
full_name="Alpha Person",
|
||||
status="active",
|
||||
current_checksum="a" * 64,
|
||||
current_data={"sections": []},
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
session.close()
|
||||
|
||||
def override_db():
|
||||
db = Session()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/mcp",
|
||||
json={"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "get_service_info", "arguments": {}}},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = json.loads(response.json()["result"]["content"][0]["text"])
|
||||
assert payload["service_name"] == "miem-employees"
|
||||
assert payload["backend_version"] == "0.7.3"
|
||||
assert payload["dataset"]["hash"]
|
||||
assert any(tool["name"] == "sync_employees" for tool in payload["tools"])
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_mcp_list_employee_publications_prefers_stored_publications_with_fallback():
|
||||
engine = create_engine(
|
||||
"sqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
Session = sessionmaker(bind=engine)
|
||||
session = Session()
|
||||
stored_employee = Employee(
|
||||
profile_key="staff:stored",
|
||||
profile_type="staff",
|
||||
profile_id="stored",
|
||||
canonical_url="https://www.hse.ru/staff/stored",
|
||||
full_name="Stored Person",
|
||||
status="active",
|
||||
current_data={
|
||||
"sections": [
|
||||
{
|
||||
"type": "publications",
|
||||
"publications": [{"title": "Old JSON Publication", "url": "https://example.test/old"}],
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
fallback_employee = Employee(
|
||||
profile_key="staff:fallback",
|
||||
profile_type="staff",
|
||||
profile_id="fallback",
|
||||
canonical_url="https://www.hse.ru/staff/fallback",
|
||||
full_name="Fallback Person",
|
||||
status="active",
|
||||
current_data={
|
||||
"sections": [
|
||||
{
|
||||
"type": "publications",
|
||||
"publications": [{"title": "Fallback Publication", "url": "https://example.test/fallback"}],
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
session.add_all([stored_employee, fallback_employee])
|
||||
session.commit()
|
||||
session.add(
|
||||
EmployeePublication(
|
||||
employee_id=stored_employee.id,
|
||||
publication_id="pub-1",
|
||||
title="Stored Publication",
|
||||
year=2024,
|
||||
publication_type="ARTICLE",
|
||||
url="https://publications.hse.ru/view/pub-1",
|
||||
doi_url="https://doi.org/10.1/test",
|
||||
citation_text="Stored Citation",
|
||||
annotation={"ru": "Аннотация", "en": "Abstract"},
|
||||
description={"main": "Stored Citation"},
|
||||
authors=[{"id": "1", "title_ru": "Автор", "is_current_employee": True}],
|
||||
source_hash="a" * 64,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
session.close()
|
||||
|
||||
def override_db():
|
||||
db = Session()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
client = TestClient(app)
|
||||
|
||||
stored_response = client.post(
|
||||
"/mcp",
|
||||
json={
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {"name": "list_employee_publications", "arguments": {"profile_id_or_url": "stored"}},
|
||||
},
|
||||
)
|
||||
fallback_response = client.post(
|
||||
"/mcp",
|
||||
json={
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/call",
|
||||
"params": {"name": "list_employee_publications", "arguments": {"profile_id_or_url": "fallback"}},
|
||||
},
|
||||
)
|
||||
|
||||
stored_payload = json.loads(stored_response.json()["result"]["content"][0]["text"])
|
||||
fallback_payload = json.loads(fallback_response.json()["result"]["content"][0]["text"])
|
||||
assert stored_payload["items"][0]["title"] == "Stored Publication"
|
||||
assert stored_payload["items"][0]["doi_url"] == "https://doi.org/10.1/test"
|
||||
assert stored_payload["items"][0]["annotation"] == {"ru": "Аннотация", "en": "Abstract"}
|
||||
assert stored_payload["items"][0]["authors"] == [{"id": "1", "title_ru": "Автор", "is_current_employee": True}]
|
||||
assert fallback_payload["items"][0]["title"] == "Fallback Publication"
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_mcp_sync_employees_full_empty_and_unknown_hash_modes():
|
||||
engine = create_engine(
|
||||
"sqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
Session = sessionmaker(bind=engine)
|
||||
session = Session()
|
||||
session.add(
|
||||
Employee(
|
||||
profile_key="staff:alpha",
|
||||
profile_type="staff",
|
||||
profile_id="alpha",
|
||||
canonical_url="https://www.hse.ru/staff/alpha",
|
||||
full_name="Alpha Person",
|
||||
status="active",
|
||||
current_checksum="a" * 64,
|
||||
current_data={"sections": [{"type": "paragraphs"}]},
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
session.close()
|
||||
|
||||
def override_db():
|
||||
db = Session()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
client = TestClient(app)
|
||||
|
||||
full_response = client.post(
|
||||
"/mcp",
|
||||
json={"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "sync_employees", "arguments": {}}},
|
||||
)
|
||||
full_payload = json.loads(full_response.json()["result"]["content"][0]["text"])
|
||||
current_hash = full_payload["to_hash"]
|
||||
|
||||
empty_response = client.post(
|
||||
"/mcp",
|
||||
json={
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/call",
|
||||
"params": {"name": "sync_employees", "arguments": {"client_hash": current_hash}},
|
||||
},
|
||||
)
|
||||
empty_payload = json.loads(empty_response.json()["result"]["content"][0]["text"])
|
||||
|
||||
unknown_response = client.post(
|
||||
"/mcp",
|
||||
json={
|
||||
"jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"method": "tools/call",
|
||||
"params": {"name": "sync_employees", "arguments": {"client_hash": "missing"}},
|
||||
},
|
||||
)
|
||||
unknown_payload = json.loads(unknown_response.json()["result"]["content"][0]["text"])
|
||||
|
||||
assert full_payload["mode"] == "full"
|
||||
assert full_payload["items"][0]["data"] == {"sections": [{"type": "paragraphs"}]}
|
||||
assert empty_payload["mode"] == "delta"
|
||||
assert empty_payload["changes"] == {"added": [], "updated": [], "dismissed": [], "removed": []}
|
||||
assert unknown_payload["mode"] == "full"
|
||||
assert unknown_payload["reason"] == "unknown_client_hash"
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_mcp_get_crawl_run_details_returns_changes():
|
||||
engine = create_engine(
|
||||
"sqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
Session = sessionmaker(bind=engine)
|
||||
session = Session()
|
||||
run = CrawlRun(source_url="https://miem.hse.ru/persons", status="completed", new_count=1)
|
||||
employee = Employee(
|
||||
profile_key="staff:new",
|
||||
profile_type="staff",
|
||||
profile_id="new",
|
||||
canonical_url="https://www.hse.ru/staff/new",
|
||||
full_name="New Person",
|
||||
status="active",
|
||||
first_seen_at=datetime.now(timezone.utc),
|
||||
last_seen_at=datetime.now(timezone.utc),
|
||||
)
|
||||
session.add_all([run, employee])
|
||||
session.commit()
|
||||
session.add(
|
||||
CrawlRunEmployeeChange(
|
||||
crawl_run_id=run.id,
|
||||
employee_id=employee.id,
|
||||
profile_key=employee.profile_key,
|
||||
profile_url=employee.canonical_url,
|
||||
full_name=employee.full_name,
|
||||
change_type="new",
|
||||
profile_available=True,
|
||||
message="added",
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
run_id = run.id
|
||||
session.close()
|
||||
|
||||
def override_db():
|
||||
db = Session()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/mcp",
|
||||
json={
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {"name": "get_crawl_run_details", "arguments": {"run_id": run_id}},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
text = response.json()["result"]["content"][0]["text"]
|
||||
assert "New Person" in text
|
||||
assert "changes_detail_available" in text
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_mcp_protected_resource_metadata_route_is_removed():
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/.well-known/oauth-protected-resource")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_api_employees_and_stats_require_admin_session():
|
||||
engine = create_engine(
|
||||
"sqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
Session = sessionmaker(bind=engine)
|
||||
db = Session()
|
||||
db.add(
|
||||
Employee(
|
||||
profile_key="staff:alpha",
|
||||
profile_type="staff",
|
||||
profile_id="alpha",
|
||||
canonical_url="https://www.hse.ru/staff/alpha",
|
||||
full_name="Alpha Person",
|
||||
status="active",
|
||||
first_seen_at=datetime.now(timezone.utc),
|
||||
last_seen_at=datetime.now(timezone.utc),
|
||||
current_data={"contacts": {"emails": ["alpha@hse.ru"]}, "sections": []},
|
||||
)
|
||||
)
|
||||
run = CrawlRun(source_url="https://miem.hse.ru/persons", status="completed", new_count=1)
|
||||
db.add(run)
|
||||
db.commit()
|
||||
db.add(
|
||||
CrawlRunEmployeeChange(
|
||||
crawl_run_id=run.id,
|
||||
employee_id=1,
|
||||
profile_key="staff:alpha",
|
||||
profile_url="https://www.hse.ru/staff/alpha",
|
||||
full_name="Alpha Person",
|
||||
change_type="new",
|
||||
profile_available=True,
|
||||
message="added",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
run_id = run.id
|
||||
db.close()
|
||||
|
||||
settings = Settings(admin_username="admin", admin_password="password", session_secret="session-secret")
|
||||
|
||||
def override_db():
|
||||
session = Session()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
app.dependency_overrides[get_settings] = lambda: settings
|
||||
client = TestClient(app)
|
||||
client.cookies.set(SESSION_COOKIE, sign_session("admin", settings))
|
||||
|
||||
employees = client.get("/api/employees", params={"q": "Alpha", "has_email": True})
|
||||
stats = client.get("/api/stats")
|
||||
run_details = client.get(f"/api/crawl-runs/{run_id}")
|
||||
|
||||
assert employees.status_code == 200
|
||||
assert employees.json()["total"] == 1
|
||||
assert stats.status_code == 200
|
||||
assert stats.json()["new_in_last_run"] == 1
|
||||
assert run_details.status_code == 200
|
||||
assert run_details.json()["changes"]["new"][0]["full_name"] == "Alpha Person"
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_admin_refresh_employee_route_updates_only_requested_employee(monkeypatch):
|
||||
engine = create_engine(
|
||||
"sqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
Session = sessionmaker(bind=engine)
|
||||
db = Session()
|
||||
db.add(
|
||||
Employee(
|
||||
profile_key="org_person:133709486",
|
||||
profile_type="org_person",
|
||||
profile_id="133709486",
|
||||
canonical_url="https://www.hse.ru/org/persons/133709486",
|
||||
full_name="Будков Юрий Алексеевич",
|
||||
status="active",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
employee_id = db.scalar(select(Employee.id))
|
||||
db.close()
|
||||
|
||||
settings = Settings(admin_username="admin", admin_password="password", session_secret="session-secret")
|
||||
|
||||
def override_db():
|
||||
session = Session()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_refresh_employee(db, refreshed_employee, route_settings):
|
||||
calls.append((refreshed_employee.id, route_settings))
|
||||
return SimpleNamespace(status="completed")
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
app.dependency_overrides[get_settings] = lambda: settings
|
||||
monkeypatch.setattr("app.admin.refresh_employee", fake_refresh_employee)
|
||||
client = TestClient(app)
|
||||
client.cookies.set(SESSION_COOKIE, sign_session("admin", settings))
|
||||
|
||||
response = client.post(f"/admin/employees/{employee_id}/refresh", follow_redirects=False)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == f"/admin/employees/{employee_id}?refresh_status=success"
|
||||
assert calls == [(employee_id, settings)]
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
@@ -11,3 +11,12 @@ def test_numeric_crawl_limit_is_parsed():
|
||||
settings = Settings(crawl_limit="25")
|
||||
|
||||
assert settings.crawl_limit == 25
|
||||
|
||||
|
||||
def test_dismissal_safety_settings_have_conservative_defaults(monkeypatch):
|
||||
monkeypatch.delenv("DISMISSAL_CONFIRMATION_RUNS", raising=False)
|
||||
monkeypatch.delenv("MAX_AUTO_DISMISSALS_PER_RUN", raising=False)
|
||||
settings = Settings(_env_file=None)
|
||||
|
||||
assert settings.dismissal_confirmation_runs == 3
|
||||
assert settings.max_auto_dismissals_per_run == 25
|
||||
@@ -0,0 +1,310 @@
|
||||
import csv
|
||||
from datetime import date
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from openpyxl import Workbook, load_workbook
|
||||
from openpyxl.worksheet.worksheet import Worksheet
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import Employee, EmployeePublication
|
||||
from app.services import council
|
||||
from app.services.council_export import _append
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def export(monkeypatch, tmp_path):
|
||||
path = tmp_path / "articles.csv"
|
||||
def write(rows):
|
||||
fields = ["Название публикации", "Год", "DOI", "EID (Scopus)", "UT (WoS)",
|
||||
"Авторы НИУ ВШЭ", "Квартиль scopus", "Квартиль wos"]
|
||||
with path.open("w", encoding="utf-8-sig", newline="") as output:
|
||||
writer = csv.DictWriter(output, fieldnames=fields, delimiter=";")
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
council._articles_by_author.cache_clear()
|
||||
monkeypatch.setattr(council, "EXPORT", path)
|
||||
write([])
|
||||
yield write
|
||||
council._articles_by_author.cache_clear()
|
||||
|
||||
|
||||
def employee(db, name="Иванов Иван Иванович", degree="Доктор технических наук", status="active", key="doctor"):
|
||||
person = Employee(profile_key=key, canonical_url="https://www.hse.ru/staff/test", full_name=name,
|
||||
status=status, current_data={"contacts": {"emails": ["private@example.org"]},
|
||||
"sections": [{"title": "Учёные степени", "items": [degree] if degree else []}]})
|
||||
db.add(person)
|
||||
db.flush()
|
||||
return person
|
||||
|
||||
|
||||
def publication(db, person, title="Shared work", doi=None, year=2024, kind=None, **extra):
|
||||
item = EmployeePublication(employee_id=person.id, title=title, year=year, doi_url=doi,
|
||||
publication_type=kind, source_hash=f"hash-{len(db.new)}-{title}-{doi}", **extra)
|
||||
db.add(item)
|
||||
return item
|
||||
|
||||
|
||||
def row(title="Shared work", doi="10.1234/shared", year="2024", author="Иванов Иван Иванович", **extra):
|
||||
return {"Название публикации": title, "Год": year, "DOI": doi, "Авторы НИУ ВШЭ": author,
|
||||
"Квартиль scopus": "Q1", **extra}
|
||||
|
||||
|
||||
def test_council_union_counts_provenance_and_types(db_session, export):
|
||||
export([row(), row(**{"Квартиль scopus": "", "Квартиль wos": "Q2"}),
|
||||
row(title="CSV only", doi="", year="2022"),
|
||||
row(title="Not eligible", doi="10.1234/old", year="2019")])
|
||||
person = employee(db_session, degree="Доктор технических наук")
|
||||
publication(db_session, person, doi="https://dx.doi.org/10.1234/SHARED", kind="ARTICLE",
|
||||
publication_id="100", url="https://publications.hse.ru/view/100")
|
||||
publication(db_session, person, title=" shared WORK ", kind="ARTICLE")
|
||||
publication(db_session, person, title="Book only", kind="BOOK", year=2020)
|
||||
publication(db_session, person, title="Imported", raw_data={"csv_enrichment": {"Квартиль wos": "Q2"}})
|
||||
db_session.commit()
|
||||
result = council.council_candidates(db_session, date(2026, 9, 30))
|
||||
assert result["total"] == 1
|
||||
candidate = result["candidates"][0]
|
||||
assert (candidate["csv_count"], candidate["db_count"], candidate["count"]) == (2, 3, 5)
|
||||
shared = next(a for a in candidate["articles"] if a["doi"] == "10.1234/shared")
|
||||
assert shared["type_label"] == "Статья"
|
||||
assert shared["quartiles"] == ["Scopus Q1", "Web of Science Q2"]
|
||||
assert shared["rows"] == [2, 3]
|
||||
assert "Файл CSV" in shared["origins"] and "БД miem-workers · сайт ВШЭ" in shared["origins"]
|
||||
imported = next(a for a in candidate["articles"] if a["title"] == "Imported")
|
||||
assert imported["origins"] == ["БД miem-workers · импорт CSV"]
|
||||
assert not imported["csv_qualifies"]
|
||||
assert "Тип не указан" in imported["type_label"]
|
||||
assert council.period(date(2024, 2, 29))[0] == date(2019, 2, 28)
|
||||
|
||||
|
||||
def test_only_doctors_with_a_publication_no_count_threshold(db_session, export):
|
||||
export([row(author="Доктор", title="Single qualifying"),
|
||||
row(author="Без степени", title="No degree"),
|
||||
row(author="Кандидат", title="Candidate work"),
|
||||
row(author="PhD", title="PhD work"),
|
||||
row(author="Две степени", title="Mixed degrees", **{"Квартиль scopus": "Q3"}),
|
||||
row(author="Уволен", title="Old", year="2010")])
|
||||
employee(db_session, "Доктор", key="csv-only")
|
||||
for name, degree in [("Без степени", None), ("Кандидат", "Кандидат технических наук"), ("PhD", "Ph.D.")]:
|
||||
person = employee(db_session, name, degree, key=name)
|
||||
publication(db_session, person)
|
||||
employee(db_session, "Две степени", "Кандидат технических наук; Доктор физико-математических наук", key="mixed")
|
||||
employee(db_session, "Уволен", status="dismissed", key="dismissed")
|
||||
only_db = employee(db_session, "Только БД", key="database")
|
||||
publication(db_session, only_db, kind="PATENT")
|
||||
employee(db_session, "Без публикаций", key="empty")
|
||||
db_session.commit()
|
||||
items = council.council_candidates(db_session, date(2026, 9, 30))["candidates"]
|
||||
assert {c["name"] for c in items} == {"Доктор", "Две степени", "Уволен", "Только БД"}
|
||||
assert next(c for c in items if c["name"] == "Доктор")["csv_count"] == 1
|
||||
assert next(c for c in items if c["name"] == "Только БД")["db_count"] == 1
|
||||
assert next(c for c in items if c["name"] == "Две степени")["csv_count"] == 0
|
||||
assert next(c for c in items if c["name"] == "Уволен")["dismissed"]
|
||||
|
||||
|
||||
def test_no_false_merge_when_title_has_conflicting_dois(db_session, export):
|
||||
export([row(doi="10.1234/one"), row(doi="10.1234/two"), row(doi="")])
|
||||
person = employee(db_session)
|
||||
publication(db_session, person, title="Renamed work", doi="https://doi.org/10.1234/one")
|
||||
db_session.commit()
|
||||
item = council.council_candidates(db_session, date(2026, 9, 30))["candidates"][0]
|
||||
assert item["count"] == 3
|
||||
assert item["csv_count"] == 3 and item["db_count"] == 1
|
||||
|
||||
|
||||
def test_csv_same_name_does_not_attribute_to_wrong_employee(db_session, export):
|
||||
export([row()])
|
||||
for key in ["one", "two"]:
|
||||
person = employee(db_session, key=key)
|
||||
publication(db_session, person, title=key)
|
||||
db_session.commit()
|
||||
items = council.council_candidates(db_session, date(2026, 9, 30))["candidates"]
|
||||
assert len(items) == 2
|
||||
assert all(c["ambiguous"] and c["csv_count"] == 0 and c["count"] == 1 for c in items)
|
||||
|
||||
|
||||
def test_missing_database_year_merges_only_unambiguous_title_and_non_article_excluded(db_session, export):
|
||||
export([row(title="Known book"), row(title="Article", doi="10.1234/article")])
|
||||
person = employee(db_session)
|
||||
publication(db_session, person, title="Known book", year=None, kind="BOOK")
|
||||
publication(db_session, person, title="Article", year=None, kind="ARTICLE")
|
||||
db_session.commit()
|
||||
candidate = council.council_candidates(db_session, date(2026, 9, 30))["candidates"][0]
|
||||
assert (candidate["count"], candidate["csv_count"], candidate["db_count"]) == (2, 1, 2)
|
||||
book = next(a for a in candidate["articles"] if a["type_label"] == "Книга")
|
||||
assert not book["csv_qualifies"]
|
||||
assert "не включён в счётчик CSV" in book["criteria"][-1]
|
||||
|
||||
|
||||
def test_search_and_pagination_include_all_workers(db_session, export):
|
||||
for number in range(28):
|
||||
person = employee(db_session, f"Петров {number:02d}", key=f"person-{number}")
|
||||
publication(db_session, person, title=f"Work {number}")
|
||||
db_session.commit()
|
||||
first = council.council_candidates(db_session)
|
||||
last = council.council_candidates(db_session, offset=25)
|
||||
assert first["total"] == 28 and len(first["candidates"]) == 25
|
||||
assert len(last["candidates"]) == 3
|
||||
assert {c["name"] for c in first["candidates"]}.isdisjoint(c["name"] for c in last["candidates"])
|
||||
assert council.council_candidates(db_session, q="петров 27")["matched"] == 1
|
||||
assert council.council_candidates(db_session, offset=-1)["offset"] == 0
|
||||
assert council.council_candidates(db_session, offset=999)["offset"] == 25
|
||||
descending = council.council_candidates(db_session, sort="full_name", direction="desc")
|
||||
last_descending = council.council_candidates(db_session, sort="full_name", direction="desc", offset=25)
|
||||
assert descending["candidates"][0]["name"] == "Петров 27"
|
||||
assert [c["name"] for c in last_descending["candidates"]] == ["Петров 02", "Петров 01", "Петров 00"]
|
||||
|
||||
|
||||
def test_sort_by_name_and_each_unique_publication_count(db_session, export):
|
||||
export([row(author="Анна", title=f"CSV {number}", doi=f"10.1234/csv-{number}") for number in range(3)]
|
||||
+ [row(author="Борис", title="CSV Boris", doi="10.1234/boris")])
|
||||
anna = employee(db_session, "Анна", key="anna")
|
||||
publication(db_session, anna, title="CSV 0", doi="https://doi.org/10.1234/csv-0")
|
||||
for name, count in [("Борис", 2), ("Вера", 4)]:
|
||||
person = employee(db_session, name, key=name)
|
||||
for number in range(count):
|
||||
publication(db_session, person, title=f"DB {name} {number}")
|
||||
db_session.commit()
|
||||
expected = {
|
||||
"full_name": {"asc": ["Анна", "Борис", "Вера"], "desc": ["Вера", "Борис", "Анна"]},
|
||||
"csv_count": {"asc": ["Вера", "Борис", "Анна"], "desc": ["Анна", "Борис", "Вера"]},
|
||||
"db_count": {"asc": ["Анна", "Борис", "Вера"], "desc": ["Вера", "Борис", "Анна"]},
|
||||
"count": {"asc": ["Анна", "Борис", "Вера"], "desc": ["Вера", "Анна", "Борис"]},
|
||||
}
|
||||
for sort, directions in expected.items():
|
||||
for direction, names in directions.items():
|
||||
result = council.council_candidates(db_session, sort=sort, direction=direction)
|
||||
assert [c["name"] for c in result["candidates"]] == names
|
||||
invalid = council.council_candidates(db_session, sort="unknown", direction="unknown")
|
||||
assert (invalid["sort"], invalid["direction"]) == ("csv_count", "desc")
|
||||
assert [c["name"] for c in invalid["candidates"]] == expected["csv_count"]["desc"]
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
try:
|
||||
response = TestClient(app).get("/admin/council?sort=full_name&direction=desc")
|
||||
assert response.status_code == 200
|
||||
assert response.text.index("Вера") < response.text.index("Борис") < response.text.index("Анна")
|
||||
assert '<option value="full_name" selected>' in response.text
|
||||
assert '<option value="desc" selected>' in response.text
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_public_page_data_minimization_and_safe_links(db_session, export):
|
||||
export([row(title='<script>alert("csv")</script>', doi="javascript:bad")])
|
||||
person = employee(db_session)
|
||||
person.canonical_url = "javascript:bad"
|
||||
publication(db_session, person, url="javascript:alert(1)", doi="javascript:bad", kind="BOOK")
|
||||
db_session.commit()
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
try:
|
||||
client = TestClient(app)
|
||||
response = client.get("/admin/council", follow_redirects=False)
|
||||
assert response.status_code == 200
|
||||
assert "Иванов Иван Иванович" in response.text and "Доктор технических наук" in response.text
|
||||
assert "Минимальный порог количества работ не применяется" in response.text
|
||||
assert "CSV по критериям" in response.text and "В БД" in response.text and "Всего без дублей" in response.text
|
||||
assert "Книга" in response.text and "Тип не указан" in response.text
|
||||
assert "<script>" in response.text and "<script>" not in response.text
|
||||
assert "javascript:" not in response.text and "private@example.org" not in response.text
|
||||
assert "/admin/employees/" not in response.text and "/admin/logout" not in response.text
|
||||
assert "Backend 0.13.1 · Frontend 0.13.1" in response.text
|
||||
assert "Вход в админку" in response.text
|
||||
assert client.get("/admin/directory", follow_redirects=False).status_code in (302, 303, 401, 403)
|
||||
assert "Сотрудники не найдены" in client.get("/admin/council?q=missing").text
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_public_error_state_missing_export_and_database_failure(db_session, export, monkeypatch):
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
try:
|
||||
council.EXPORT.unlink()
|
||||
response = TestClient(app).get("/admin/council")
|
||||
assert response.status_code == 503 and "Повторить загрузку" in response.text
|
||||
def fail(*args, **kwargs):
|
||||
raise OperationalError("query", {}, Exception("private error"))
|
||||
monkeypatch.setattr("app.admin.council_candidates", fail)
|
||||
response = TestClient(app).get("/admin/council")
|
||||
assert response.status_code == 503 and "private error" not in response.text
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_xlsx_exports_all_filtered_workers_in_order_and_deduplicated_evidence(db_session, export):
|
||||
export([row(author="Петров 27"), row(author="Петров 27", **{"Квартиль wos": "Q2"})])
|
||||
for number in range(28):
|
||||
person = employee(db_session, f"Петров {number:02d}", key=f"xlsx-{number}")
|
||||
publication(db_session, person, title="Shared work" if number == 27 else f"Work {number}",
|
||||
doi="https://doi.org/10.1234/shared" if number == 27 else None,
|
||||
kind="ARTICLE", publication_id=str(number), url=f"https://publications.hse.ru/view/{number}")
|
||||
excluded = employee(db_session, "Без степени", degree=None, key="excluded")
|
||||
publication(db_session, excluded)
|
||||
db_session.commit()
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
try:
|
||||
client = TestClient(app)
|
||||
response = client.get("/admin/council/export.xlsx?q=Петров&sort=full_name&direction=desc&offset=25")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
assert response.headers["content-disposition"] == 'attachment; filename="council.xlsx"'
|
||||
book = load_workbook(BytesIO(response.content))
|
||||
assert book.sheetnames == ["Сотрудники", "Публикации", "Параметры"]
|
||||
people, works = book["Сотрудники"], book["Публикации"]
|
||||
assert people.max_row == 29 and works.max_row == 29
|
||||
assert people["B2"].value == "Петров 27" and people["B29"].value == "Петров 00"
|
||||
assert [people.cell(2, n).value for n in [4, 5, 6]] == [1, 1, 1]
|
||||
assert works["C2"].value == "Shared work" and works["E2"].value == "Статья"
|
||||
assert "Файл CSV" in works["F2"].value and "сайт ВШЭ" in works["F2"].value
|
||||
assert "Scopus Q1" in works["G2"].value and "Web of Science Q2" in works["G2"].value
|
||||
assert works["J2"].value == "https://doi.org/10.1234/shared" and works["L2"].value == "2, 3"
|
||||
assert people["D2"].data_type == "n" and people.freeze_panes == "A2"
|
||||
assert works.auto_filter.ref == works.dimensions
|
||||
text = " ".join(str(cell.value) for sheet in book for row in sheet for cell in row)
|
||||
assert "private@example.org" not in text and "Без степени" not in text
|
||||
assert load_workbook(BytesIO(client.get("/admin/council/export.xlsx?q=Петров%2027").content))["Сотрудники"].max_row == 2
|
||||
empty = load_workbook(BytesIO(client.get("/admin/council/export.xlsx?q=missing").content))
|
||||
assert empty["Сотрудники"].max_row == 1 and empty["Публикации"].max_row == 1
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_xlsx_treats_profile_text_as_text_and_rejects_silent_truncation(db_session, export):
|
||||
person = employee(db_session, '=HYPERLINK("https://example.org","name")')
|
||||
publication(db_session, person, title="=1+1\x01")
|
||||
publication(db_session, person, title="#N/A")
|
||||
db_session.commit()
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
try:
|
||||
client = TestClient(app)
|
||||
response = client.get("/admin/council/export.xlsx")
|
||||
assert response.status_code == 200
|
||||
book = load_workbook(BytesIO(response.content))
|
||||
assert book["Сотрудники"]["B2"].value == person.full_name
|
||||
assert book["Сотрудники"]["B2"].data_type == "s"
|
||||
titles = {book["Публикации"].cell(n, 3).value: book["Публикации"].cell(n, 3).data_type for n in [2, 3]}
|
||||
assert titles == {"=1+1": "s", "#N/A": "s"}
|
||||
with ZipFile(BytesIO(response.content)) as archive:
|
||||
assert all(b"<f>" not in archive.read(n) for n in archive.namelist() if n.startswith("xl/worksheets/"))
|
||||
person.full_name = "Long " + "x" * 32768
|
||||
db_session.commit()
|
||||
failure = client.get("/admin/council/export.xlsx")
|
||||
assert failure.status_code == 503 and "Не удалось создать XLSX" in failure.text
|
||||
assert "content-disposition" not in failure.headers
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_xlsx_appending_rows_does_not_scan_growing_sheet(monkeypatch):
|
||||
def fail_on_scan(sheet):
|
||||
pytest.fail("Appending rows must not recompute worksheet bounds by scanning existing cells")
|
||||
monkeypatch.setattr(Worksheet, "max_row", property(fail_on_scan))
|
||||
monkeypatch.setattr(Worksheet, "max_column", property(fail_on_scan))
|
||||
sheet = Workbook().active
|
||||
for number in range(1000):
|
||||
_append(sheet, [number, "text"])
|
||||
assert sheet.cell(1000, 1).value == 999
|
||||
assert sheet.cell(1000, 2).value == "text"
|
||||
+117
-2
@@ -1,4 +1,5 @@
|
||||
import gzip
|
||||
import gzip
|
||||
import pytest
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.models import (
|
||||
@@ -434,7 +435,7 @@ def test_upsert_employee_saves_publications_and_reuses_existing_rows(db_session)
|
||||
assert publications[0].authors == [{"id": "1", "title_ru": "Автор"}]
|
||||
|
||||
|
||||
def test_upsert_employee_records_publication_errors_without_failing_employee(monkeypatch, db_session):
|
||||
def test_upsert_employee_records_publication_errors_without_failing_employee(monkeypatch, db_session):
|
||||
run = CrawlRun(source_url="https://miem.hse.ru/persons", status="running")
|
||||
db_session.add(run)
|
||||
db_session.commit()
|
||||
@@ -573,3 +574,117 @@ def _parsed_employee_with_news(profile_id: str) -> dict:
|
||||
}
|
||||
]
|
||||
return parsed
|
||||
|
||||
|
||||
def test_csv_enrichment_survives_repeated_crawl_without_duplicate(db_session):
|
||||
from scripts.import_publications import plan
|
||||
from app.services.crawler import _sync_employee_publications
|
||||
|
||||
run = CrawlRun(source_url="https://miem.hse.ru/persons", status="running")
|
||||
db_session.add(run)
|
||||
db_session.commit()
|
||||
employee, _ = _upsert_employee(db_session, run, _parsed_employee_with_publication("csv-preserved"))
|
||||
db_session.commit()
|
||||
existing = db_session.query(EmployeePublication).filter_by(employee_id=employee.id).one()
|
||||
rows = [(number, {"Название публикации": title, "Год": str(year), "DOI": identifier,
|
||||
"Авторы НИУ ВШЭ": employee.full_name, "Квартиль scopus": "Q1"})
|
||||
for number, title, year, identifier in (
|
||||
(2, existing.title, existing.year, ""),
|
||||
(3, "CSV only", 2024, "10.1234/csv-only"),
|
||||
(4, "Later HSE", 2024, "10.1234/later"))]
|
||||
actions, _ = plan(rows, [{"id": employee.id, "full_name": employee.full_name}],
|
||||
[{field: getattr(existing, field) for field in
|
||||
("id", "employee_id", "title", "year", "doi_url", "raw_data")}])
|
||||
for action in actions:
|
||||
if action["result"] == "insert":
|
||||
db_session.add(EmployeePublication(**action["values"]))
|
||||
else:
|
||||
for field, value in action["values"].items():
|
||||
setattr(existing, field, value)
|
||||
db_session.add(EmployeePublication(employee_id=employee.id, title="Obsolete HSE",
|
||||
source_hash="obsolete", raw_data={"id": "obsolete"}))
|
||||
db_session.commit()
|
||||
original_id = existing.id
|
||||
imported_ids = {p.title: p.id for p in db_session.query(EmployeePublication).all()}
|
||||
|
||||
parsed = _parsed_employee_with_publication("csv-preserved")
|
||||
publication = parsed["sections"][0]["publications"][0]
|
||||
publication.pop("year")
|
||||
publication.pop("doi_url")
|
||||
publication["raw_data"]["fresh"] = True
|
||||
for _ in range(2):
|
||||
_sync_employee_publications(db_session, employee, parsed)
|
||||
db_session.commit()
|
||||
remaining = db_session.query(EmployeePublication).filter_by(employee_id=employee.id).all()
|
||||
assert len(remaining) == 3
|
||||
assert all(p.raw_data["csv_enrichment"]["Квартиль scopus"] == "Q1" for p in remaining)
|
||||
refreshed = db_session.get(EmployeePublication, original_id)
|
||||
assert refreshed.raw_data["fresh"] is True
|
||||
assert refreshed.year == 2023 and refreshed.doi_url == "https://doi.org/10.1/test"
|
||||
assert db_session.get(EmployeePublication, imported_ids["CSV only"]) is not None
|
||||
|
||||
parsed["sections"][0]["publications"].append({
|
||||
"publication_id": "later-id", "title": "Later HSE", "year": 2024,
|
||||
"doi_url": "http://dx.doi.org/10.1234/LATER", "raw_data": {"id": "later-id"}})
|
||||
for _ in range(2):
|
||||
_sync_employee_publications(db_session, employee, parsed)
|
||||
db_session.commit()
|
||||
assert db_session.query(EmployeePublication).filter_by(employee_id=employee.id).count() == 3
|
||||
adopted = db_session.get(EmployeePublication, imported_ids["Later HSE"])
|
||||
assert adopted.publication_id == "later-id"
|
||||
assert adopted.raw_data["csv_enrichment"]["Квартиль scopus"] == "Q1"
|
||||
|
||||
|
||||
def test_ambiguous_csv_match_rolls_back_publication_sync(db_session):
|
||||
from app.services.crawler import _try_sync_employee_publications
|
||||
run = CrawlRun(source_url="https://miem.hse.ru/persons", status="running")
|
||||
db_session.add(run)
|
||||
db_session.commit()
|
||||
employee, _ = _upsert_employee(db_session, run, _parsed_employee("csv-ambiguous"))
|
||||
for index in range(2):
|
||||
db_session.add(EmployeePublication(employee_id=employee.id, title="Same", year=2024,
|
||||
doi_url="https://doi.org/10.1234/same", source_hash=f"csv-{index}",
|
||||
raw_data={"csv_enrichment": {"Год": "2024"}}))
|
||||
db_session.commit()
|
||||
parsed = {"sections": [{"type": "publications", "publications": [
|
||||
{"publication_id": "new", "title": "New"},
|
||||
{"publication_id": "same", "title": "Same", "doi_url": "https://doi.org/10.1234/same", "year": 2024}]}]}
|
||||
_try_sync_employee_publications(db_session, run, employee, parsed)
|
||||
db_session.commit()
|
||||
assert db_session.query(EmployeePublication).filter_by(employee_id=employee.id).count() == 2
|
||||
assert db_session.query(CrawlError).filter_by(crawl_run_id=run.id, error_type="ValueError").count() == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bound_id", [None, "hse-A"])
|
||||
@pytest.mark.parametrize("incoming_ids", [("hse-A", "hse-B"), ("hse-B", "hse-A")])
|
||||
def test_two_hse_ids_cannot_overwrite_one_csv_record(db_session, bound_id, incoming_ids):
|
||||
from app.services.crawler import _try_sync_employee_publications
|
||||
from app.services.admin_data import run_detail_payload
|
||||
run = CrawlRun(source_url="https://miem.hse.ru/persons", status="running")
|
||||
db_session.add(run)
|
||||
db_session.commit()
|
||||
employee, _ = _upsert_employee(db_session, run, _parsed_employee("csv-hse-id-conflict"))
|
||||
original_raw = {"csv_enrichment": {"Год": "2024"}, "original": "keep"}
|
||||
original_authors = [{"title_ru": "Synthetic original author"}]
|
||||
record = EmployeePublication(employee_id=employee.id, title="Same", year=2024,
|
||||
publication_id=bound_id, source_hash="csv-original", raw_data=original_raw,
|
||||
authors=original_authors)
|
||||
db_session.add(record)
|
||||
db_session.commit()
|
||||
original_id = record.id
|
||||
parsed = {"sections": [{"type": "publications", "publications": [
|
||||
{"publication_id": identifier, "title": "Same", "year": 2024,
|
||||
"authors": [{"id": identifier}], "raw_data": {"id": identifier}}
|
||||
for identifier in incoming_ids]}]}
|
||||
_try_sync_employee_publications(db_session, run, employee, parsed)
|
||||
db_session.commit()
|
||||
db_session.expire_all()
|
||||
kept = db_session.get(EmployeePublication, original_id)
|
||||
assert db_session.query(EmployeePublication).filter_by(employee_id=employee.id).count() == 1
|
||||
assert kept.publication_id == bound_id and kept.source_hash == "csv-original"
|
||||
assert kept.raw_data == original_raw and kept.authors == original_authors
|
||||
errors = run_detail_payload(db_session, run)["errors"]
|
||||
assert len(errors) == 1
|
||||
assert errors[0]["error_type"] == "ValueError"
|
||||
assert "Ambiguous HSE IDs" in errors[0]["message"]
|
||||
assert "hse-A" in errors[0]["message"] and "hse-B" in errors[0]["message"]
|
||||
@@ -1,88 +0,0 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.models import Employee
|
||||
from app.services.dataset_versions import get_or_create_current_version, sync_employees_payload
|
||||
|
||||
|
||||
def _employee(profile_key: str, checksum: str, *, status: str = "active") -> Employee:
|
||||
return Employee(
|
||||
profile_key=profile_key,
|
||||
profile_type=profile_key.split(":", 1)[0],
|
||||
profile_id=profile_key.split(":", 1)[1],
|
||||
canonical_url=f"https://www.hse.ru/{profile_key}",
|
||||
full_name=profile_key,
|
||||
status=status,
|
||||
first_seen_at=datetime.now(timezone.utc),
|
||||
last_seen_at=datetime.now(timezone.utc),
|
||||
current_data={"profile_key": profile_key},
|
||||
current_checksum=checksum,
|
||||
)
|
||||
|
||||
|
||||
def test_dataset_version_hash_is_stable_for_same_employee_state(db_session):
|
||||
db_session.add(_employee("staff:alpha", "a" * 64))
|
||||
db_session.commit()
|
||||
|
||||
first = get_or_create_current_version(db_session)
|
||||
db_session.commit()
|
||||
second = get_or_create_current_version(db_session)
|
||||
|
||||
assert second.id == first.id
|
||||
assert second.hash == first.hash
|
||||
assert second.employee_count == 1
|
||||
|
||||
|
||||
def test_dataset_version_hash_changes_when_employee_checksum_changes(db_session):
|
||||
employee = _employee("staff:alpha", "a" * 64)
|
||||
db_session.add(employee)
|
||||
db_session.commit()
|
||||
first = get_or_create_current_version(db_session)
|
||||
db_session.commit()
|
||||
|
||||
employee.current_checksum = "b" * 64
|
||||
db_session.commit()
|
||||
second = get_or_create_current_version(db_session)
|
||||
|
||||
assert second.hash != first.hash
|
||||
assert second.previous_hash == first.hash
|
||||
|
||||
|
||||
def test_sync_employees_diff_spans_multiple_intermediate_versions(db_session):
|
||||
alpha = _employee("staff:alpha", "a" * 64)
|
||||
db_session.add(alpha)
|
||||
db_session.commit()
|
||||
first = get_or_create_current_version(db_session)
|
||||
db_session.commit()
|
||||
|
||||
beta = _employee("staff:beta", "b" * 64)
|
||||
db_session.add(beta)
|
||||
db_session.commit()
|
||||
get_or_create_current_version(db_session)
|
||||
db_session.commit()
|
||||
|
||||
alpha.current_checksum = "c" * 64
|
||||
alpha.current_data = {"profile_key": "staff:alpha", "changed": True}
|
||||
db_session.commit()
|
||||
|
||||
payload = sync_employees_payload(db_session, client_hash=first.hash, include_data=False)
|
||||
|
||||
assert payload["mode"] == "delta"
|
||||
assert [item["profile_key"] for item in payload["changes"]["added"]] == ["staff:beta"]
|
||||
assert [item["profile_key"] for item in payload["changes"]["updated"]] == ["staff:alpha"]
|
||||
assert payload["changes"]["dismissed"] == []
|
||||
assert payload["changes"]["removed"] == []
|
||||
|
||||
|
||||
def test_sync_employees_reports_dismissed_as_tombstone(db_session):
|
||||
alpha = _employee("staff:alpha", "a" * 64)
|
||||
db_session.add(alpha)
|
||||
db_session.commit()
|
||||
first = get_or_create_current_version(db_session)
|
||||
db_session.commit()
|
||||
|
||||
alpha.status = "dismissed"
|
||||
db_session.commit()
|
||||
payload = sync_employees_payload(db_session, client_hash=first.hash, include_data=False)
|
||||
|
||||
assert payload["changes"]["dismissed"][0]["profile_key"] == "staff:alpha"
|
||||
assert payload["changes"]["dismissed"][0]["status"] == "dismissed"
|
||||
@@ -0,0 +1,70 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import textwrap
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
WORKFLOW = (Path(__file__).parents[1] / ".gitea/workflows/ci.yml").read_text()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("keygen_exit", [0, 1])
|
||||
def test_fingerprint_output_and_cleanup(tmp_path, keygen_exit):
|
||||
step = WORKFLOW.split(" - name: Deployment key fingerprint\n", 1)[1]
|
||||
step = step.split(" - name:", 1)[0]
|
||||
script = textwrap.dedent(step.split(" run: |\n", 1)[1])
|
||||
keygen = tmp_path / "ssh-keygen"
|
||||
keygen.write_text(
|
||||
"#!/bin/sh\n"
|
||||
'case "$1" in\n'
|
||||
'-y) printf "PUBLIC_MATERIAL\\n"; exit "$KEYGEN_EXIT" ;;\n'
|
||||
'-lf) cat >/dev/null; printf "256 SHA256:test comment (ED25519)\\n" ;;\n'
|
||||
'*) exit 99 ;;\n'
|
||||
"esac\n"
|
||||
)
|
||||
keygen.chmod(0o700)
|
||||
(tmp_path / "deploy_key").write_text("PRIVATE_MATERIAL")
|
||||
(tmp_path / "known_hosts").write_text("HOST_MATERIAL")
|
||||
result = subprocess.run(
|
||||
["bash", "-x", "-c", script], capture_output=True, text=True,
|
||||
env={**os.environ, "RUNNER_TEMP": str(tmp_path),
|
||||
"PATH": f"{tmp_path}:{os.environ['PATH']}",
|
||||
"KEYGEN_EXIT": str(keygen_exit)},
|
||||
)
|
||||
assert (result.returncode == 0) == (keygen_exit == 0)
|
||||
if keygen_exit == 0:
|
||||
assert result.stdout == "SHA256:test\n"
|
||||
assert "MATERIAL" not in result.stdout + result.stderr
|
||||
assert "comment" not in result.stdout + result.stderr
|
||||
assert not (tmp_path / "deploy_key").exists()
|
||||
assert not (tmp_path / "known_hosts").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("event", ["push", "pull_request", "workflow_dispatch"])
|
||||
@pytest.mark.parametrize("diagnostic_only", ["true", "false", ""])
|
||||
def test_launch_conditions(event, diagnostic_only):
|
||||
job = WORKFLOW.split("\n deploy:\n", 1)[1]
|
||||
condition = re.search(r"^ if: (.+)$", job, re.MULTILINE).group(1)
|
||||
terms = re.findall(r"(github\.[a-z_.]+) == '([^']*)'", condition)
|
||||
assert condition == " && ".join(f"{name} == '{value}'" for name, value in terms)
|
||||
context = {"github.event_name": event,
|
||||
"github.event.inputs.diagnostic_only": diagnostic_only}
|
||||
runs = all(context[name] == value for name, value in terms)
|
||||
assert runs == (event == "workflow_dispatch" and diagnostic_only == "true")
|
||||
|
||||
|
||||
def test_both_vps_steps_are_unconditionally_disabled():
|
||||
for name in ("Upload release", "Deploy and verify"):
|
||||
step = WORKFLOW.split(f" - name: {name}\n", 1)[1]
|
||||
assert step.startswith(" if: ${{ false }}\n")
|
||||
|
||||
|
||||
def test_push_and_pull_request_keep_tests():
|
||||
triggers = WORKFLOW.split("\njobs:\n", 1)[0]
|
||||
for event in ("push", "pull_request"):
|
||||
assert f" {event}:\n branches: [main]\n" in triggers
|
||||
test_job = WORKFLOW.split("\n test:\n", 1)[1].split("\n deploy:\n", 1)[0]
|
||||
assert " if:" not in test_job
|
||||
assert "python -m pytest -q" in test_job
|
||||
@@ -0,0 +1,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