Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c609cc7ea | ||
|
|
8adaa4d6ea | ||
|
|
e0f9ab7411 | ||
|
|
25eb6481f7 | ||
|
|
72afb31049 | ||
|
|
05a62d18e6 | ||
|
|
111ee15289 | ||
|
|
cb15d11df4 | ||
|
|
07bd42927b | ||
|
|
e49bcf6ba6 | ||
|
|
4cf6cba77c | ||
|
|
9fc7de113a | ||
|
|
69c1dd52cf | ||
|
|
b933f95302 | ||
|
|
384cd550d5 | ||
|
|
5c3883beca | ||
|
|
144e62a35e | ||
|
|
8102556862 | ||
|
|
711c6ae7b0 | ||
|
|
580df29849 | ||
|
|
186f2d0804 | ||
|
|
e4e64b92c1 | ||
|
|
aa43da2857 | ||
|
|
bee3dc9848 | ||
|
|
d95133e257 | ||
|
|
2bf6ff570c | ||
|
|
e58156fbeb | ||
|
|
ddb3ad1a72 | ||
|
|
f6a01500d7 | ||
|
|
ec7aec310a | ||
|
|
0f91ed0d8f | ||
|
|
1e9cc50a73 | ||
|
|
10d2cf0101 | ||
|
|
44949996bc | ||
|
|
baad49b976 | ||
|
|
ceca2017cb | ||
|
|
83e726efe5 | ||
|
|
7d6d8759a5 | ||
|
|
5d89ae3891 |
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
|
||||
|
||||
+25
-8
@@ -23,6 +23,8 @@ jobs:
|
||||
- run: python -m pip install -r requirements.txt
|
||||
- run: python -m pytest -q
|
||||
- run: node --check app/static/admin.js
|
||||
- run: sh -n infra/postgres-backup.sh
|
||||
- run: sh -n infra/install-postgres-backup-cron.sh
|
||||
|
||||
deploy:
|
||||
if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch'
|
||||
@@ -37,9 +39,15 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Prepare release
|
||||
id: prepare
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
backend_version="$(sed -n 's/^BACKEND_VERSION = "\(.*\)"$/\1/p' app/version.py)"
|
||||
case "$backend_version" in
|
||||
''|*[!0-9.]*) exit 1 ;;
|
||||
esac
|
||||
echo "backend_version=$backend_version" >> "$GITHUB_OUTPUT"
|
||||
tar --exclude=.git --exclude=.env -cf "$RUNNER_TEMP/$RELEASE_NAME" .
|
||||
printf '%s\n' "$PROD_SSH_KEY" > "$RUNNER_TEMP/deploy_key"
|
||||
printf '%s\n' "$PROD_KNOWN_HOSTS" > "$RUNNER_TEMP/known_hosts"
|
||||
@@ -55,7 +63,7 @@ jobs:
|
||||
-o BatchMode=yes \
|
||||
-o UserKnownHostsFile="$RUNNER_TEMP/known_hosts" \
|
||||
"$RUNNER_TEMP/$RELEASE_NAME" \
|
||||
"$PROD_USER@$PROD_HOST:/tmp/$RELEASE_NAME"
|
||||
"$PROD_USER@$PROD_HOST:$RELEASE_NAME"
|
||||
env:
|
||||
PROD_HOST: ${{ secrets.PROD_HOST }}
|
||||
PROD_USER: ${{ secrets.PROD_USER }}
|
||||
@@ -67,21 +75,30 @@ jobs:
|
||||
-o BatchMode=yes \
|
||||
-o UserKnownHostsFile="$RUNNER_TEMP/known_hosts" \
|
||||
"$PROD_USER@$PROD_HOST" \
|
||||
"PROD_PATH='$PROD_PATH' RELEASE_NAME='$RELEASE_NAME' bash -s" <<'REMOTE'
|
||||
"PROD_PATH='$PROD_PATH' RELEASE_NAME='$RELEASE_NAME' EXPECTED_BACKEND_VERSION='$EXPECTED_BACKEND_VERSION' bash -s" <<'REMOTE'
|
||||
set -euo pipefail
|
||||
cd "$PROD_PATH"
|
||||
mkdir -p "$HOME/backups/miem_workers"
|
||||
backup="$HOME/backups/miem_workers/miem_workers-$(date +%Y%m%d-%H%M%S).sql"
|
||||
docker compose exec -T postgres sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB"' > "$backup"
|
||||
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 "/tmp/$RELEASE_NAME:/src/release.tar:ro" \
|
||||
-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
|
||||
curl --fail --silent --show-error http://127.0.0.1:8000/api/health
|
||||
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 "/tmp/$RELEASE_NAME"
|
||||
rm -f "$release"
|
||||
REMOTE
|
||||
env:
|
||||
PROD_HOST: ${{ secrets.PROD_HOST }}
|
||||
PROD_USER: ${{ secrets.PROD_USER }}
|
||||
EXPECTED_BACKEND_VERSION: ${{ steps.prepare.outputs.backend_version }}
|
||||
@@ -1,5 +1,55 @@
|
||||
# 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 после обновления.
|
||||
|
||||
@@ -64,7 +64,7 @@ docker compose up -d --build --remove-orphans
|
||||
- API и админка: `http://localhost:8000`
|
||||
- PostgreSQL: `postgres:5432` внутри сети Compose; порт на хост не опубликован.
|
||||
|
||||
Compose запускает `api`, `worker` и `postgres`. API привязан к localhost; для внешнего доступа нужен настроенный reverse proxy. MCP удалён, endpoint `/mcp` отсутствует; REST API данных требует сессию администратора.
|
||||
Compose запускает `api`, `worker` и `postgres`. API привязан к localhost; для внешнего доступа нужен настроенный reverse proxy. REST API данных требует сессию администратора.
|
||||
|
||||
Таблицы создаются приложением при старте. При обновлении существующей базы приложение также добавляет недостающие runtime-колонки, например `crawl_runs.skipped_count`. SQL-миграции для ручного применения лежат в `migrations/`.
|
||||
|
||||
@@ -77,14 +77,20 @@ Compose запускает `api`, `worker` и `postgres`. API привязан
|
||||
- краткий список остается внутри `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 не должен создавать дубликаты.
|
||||
|
||||
Новости сотрудников также хранятся в двух видах:
|
||||
`employee_publications` содержит `publication_id`, название, год, тип публикации, язык, статус, ссылку на карточку HSE Publications, DOI, внешние/document-ссылки, citation text, аннотацию, описание, авторов, raw JSON ответа `searchPubs` и `source_hash` для безопасного повторного upsert. Уникальность поддерживается по `(employee_id, publication_id)` и `(employee_id, source_hash)`, поэтому повторный crawl не должен создавать дубликаты.
|
||||
|
||||
Новости сотрудников также хранятся в двух видах:
|
||||
|
||||
- краткий список остается внутри `employees.current_data.sections[].news_links`;
|
||||
- нормализованные карточки из вкладки «В новостях» сохраняются в `employee_news_links`.
|
||||
|
||||
`employee_news_links` содержит название новости, ссылку, краткое описание, дату публикации, год публикации, raw JSON карточки и `source_hash`. Уникальность поддерживается по `(employee_id, url)` и `(employee_id, source_hash)`, поэтому повторный crawl не создает дубликаты.
|
||||
`employee_news_links` содержит название новости, ссылку, краткое описание, дату публикации, год публикации, raw JSON карточки и `source_hash`. Уникальность поддерживается по `(employee_id, url)` и `(employee_id, source_hash)`, поэтому повторный crawl не создает дубликаты.
|
||||
|
||||
## Кандидаты в диссертационный совет
|
||||
|
||||
Ссылка в админке открывает `/admin/council`. Страница использует действующих сотрудников и докторские степени из их профилей, а публикации — из предоставленной выгрузки `app/data/council_articles.csv`. Публикации сопоставляются только по полному ФИО при единственном совпадении; повторный DOI (или одинаковые название и год при отсутствии DOI) учитывается один раз. Порог — 10 предварительно подходящих публикаций за скользящие пять лет от текущей даты по Москве.
|
||||
|
||||
Список **предварительный**: в CSV указаны год и квартиль Scopus/WoS, но нет точной даты, типа материала, журнала, источника квартиля, подтверждения К1 и RSCI. На границах периода год не позволяет проверить точную дату. Статьи с Q1/Q2 учитываются с пометкой источника; отсутствие кандидата не доказывает недостаточную публикационную активность. Для подтверждённого отбора нужны проверка журналов и дат и дополнительные источники публикаций. Выгрузка поставляется вместе с релизом и обновляется отдельным изменением репозитория.
|
||||
|
||||
## Парсинг
|
||||
|
||||
@@ -122,7 +128,7 @@ docker compose exec postgres pg_dump -U miem miem_workers > backup.sql
|
||||
docker compose down
|
||||
```
|
||||
|
||||
Production deploy выполняется job `deploy` workflow `CI` после успешного `test` на push в `main` или вручную. Для него нужны Actions secrets `PROD_HOST`, `PROD_USER`, `PROD_SSH_KEY` и `PROD_KNOWN_HOSTS`; `.env` и ключи в репозиторий не добавляются. Перед обновлением workflow сохраняет PostgreSQL backup в домашний каталог deploy-пользователя, пересобирает `api` и `worker` и проверяет `/api/health`. Одновременно выполняется только один production deploy.
|
||||
Production deploy выполняется job `deploy` workflow `CI` после успешного `test` на push в `main` или вручную. Для него нужны Actions secrets `PROD_HOST`, `PROD_USER`, `PROD_SSH_KEY` и `PROD_KNOWN_HOSTS`; `.env` и ключи в репозиторий не добавляются. PostgreSQL backup выполняется на VPS раз в неделю user cron deploy-пользователя через `infra/postgres-backup.sh`: `pg_dumpall` сохраняет весь кластер в `~/backups/postgres/postgres-latest.sql`. Новый dump сначала создаётся во временный файл и заменяет старый только после успешного завершения; предыдущие dump-файлы `miem_workers` удаляются после успешного создания нового. Одновременно выполняется только один production deploy.
|
||||
|
||||
## Проверки
|
||||
|
||||
@@ -135,4 +141,12 @@ node --check app/static/admin.js
|
||||
|
||||
Тесты с данными используют временную SQLite; отдельные API smoke-тесты запускают приложение с его текущей конфигурацией. Браузерная проверка: `pip install playwright`, `python -m playwright install chromium`, затем `python tests/browser_admin.py`. Она проверяет реальный рендеринг страниц, клавиатуру, диалог, фильтры и восстановление прогресса после ошибки. Скриншоты сохраняются во временную папку; путь выводится в конце.
|
||||
|
||||
Версия сервиса: `0.8.2`. Админка всегда показывает версии backend и frontend в footer.
|
||||
Версия сервиса: `0.10.4`. Админка всегда показывает версии backend и frontend в footer.
|
||||
|
||||
## Разовый импорт публикаций
|
||||
|
||||
Инструкция, минимальные права временной роли и ограниченный SSH-запуск: [docs/publication-import.md](docs/publication-import.md). По умолчанию импортер выполняет dry-run; production-применение требует отдельного решения владельца.
|
||||
|
||||
Интеграционная проверка импорта на отдельном PostgreSQL: [docs/postgresql-import-verification.md](docs/postgresql-import-verification.md).
|
||||
|
||||
Отдельный тестовый стенд на VPS и read-only проверка ресурсов: [docs/postgresql-import-vps.md](docs/postgresql-import-vps.md). Стенд запускает только владелец после оценки ресурсов.
|
||||
+19
-7
@@ -17,8 +17,9 @@ from app.services.admin_data import (
|
||||
run_payload,
|
||||
stats_payload,
|
||||
)
|
||||
from app.services.crawl_control import get_running_run, run_crawl_if_idle
|
||||
from app.services.crawl_control import get_running_run, run_crawl_if_idle
|
||||
from app.services.crawler import refresh_dismissed_status, refresh_employee
|
||||
from app.services.council import council_candidates
|
||||
from app.version import BACKEND_VERSION, FRONTEND_VERSION
|
||||
|
||||
router = APIRouter(prefix="/admin")
|
||||
@@ -73,8 +74,8 @@ def employees(
|
||||
return RedirectResponse("/admin/directory", status_code=303)
|
||||
|
||||
|
||||
@router.get("/directory", response_class=HTMLResponse)
|
||||
def directory(
|
||||
@router.get("/directory", response_class=HTMLResponse)
|
||||
def directory(
|
||||
request: Request,
|
||||
status: str | None = None,
|
||||
q: str | None = None,
|
||||
@@ -129,10 +130,21 @@ def directory(
|
||||
"offset": page["offset"],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/employees/{employee_id}", response_class=HTMLResponse)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/council", response_class=HTMLResponse)
|
||||
def council(request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
require_admin(request, settings)
|
||||
try:
|
||||
result = council_candidates(db)
|
||||
except (SQLAlchemyError, OSError, ValueError, KeyError):
|
||||
db.rollback()
|
||||
return _render(request, "council.html", {"error": True}, status_code=503)
|
||||
return _render(request, "council.html", {"error": False, **result})
|
||||
|
||||
|
||||
@router.get("/employees/{employee_id}", response_class=HTMLResponse)
|
||||
def employee_detail(
|
||||
employee_id: int,
|
||||
request: Request,
|
||||
|
||||
@@ -13,6 +13,8 @@ class Settings(BaseSettings):
|
||||
request_timeout: int = 30
|
||||
request_delay_seconds: float = 1.0
|
||||
parser_use_playwright: bool = False
|
||||
dismissal_confirmation_runs: int = Field(default=3, ge=1)
|
||||
max_auto_dismissals_per_run: int = Field(default=25, ge=1)
|
||||
|
||||
admin_username: str = "admin"
|
||||
admin_password: str = "admin"
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,117 @@
|
||||
"""Preliminary council candidates from the supplied publication export."""
|
||||
|
||||
import calendar
|
||||
import csv
|
||||
import re
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import date, datetime
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Employee
|
||||
from app.services.academic_degrees import academic_degrees
|
||||
|
||||
|
||||
EXPORT = Path(__file__).resolve().parents[1] / "data" / "council_articles.csv"
|
||||
DOCTOR = re.compile(r"^доктор\b.+\bнаук$", re.IGNORECASE)
|
||||
DOI = re.compile(r"10\.\d{4,9}/\S+", re.IGNORECASE)
|
||||
|
||||
|
||||
def _name(value: str) -> str:
|
||||
return " ".join(value.casefold().replace("ё", "е").split())
|
||||
|
||||
|
||||
def _doi(value: str) -> str:
|
||||
value = value.strip().lower()
|
||||
for prefix in ("https://doi.org/", "http://doi.org/", "doi:"):
|
||||
if value.startswith(prefix):
|
||||
value = value[len(prefix):]
|
||||
return value
|
||||
|
||||
|
||||
def period(today: date) -> tuple[date, date]:
|
||||
year = today.year - 5
|
||||
return date(year, today.month, min(today.day, calendar.monthrange(year, today.month)[1])), today
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _articles_by_author() -> dict[str, list[dict]]:
|
||||
articles = defaultdict(list)
|
||||
with EXPORT.open(encoding="utf-8-sig", newline="") as source:
|
||||
for row_number, row in enumerate(csv.DictReader(source, delimiter=";"), 2):
|
||||
sources = []
|
||||
for label, quartile, identifier in (
|
||||
("Scopus", "Квартиль scopus", "EID (Scopus)"),
|
||||
("Web of Science", "Квартиль wos", "UT (WoS)"),
|
||||
):
|
||||
value = (row[quartile] or "").strip().upper()
|
||||
if value in {"Q1", "Q2"}:
|
||||
sources.append({"name": label, "quartile": value, "identifier": (row[identifier] or "").strip()})
|
||||
if not sources:
|
||||
continue
|
||||
try:
|
||||
year = int(row["Год"])
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
title = (row["Название публикации"] or "").strip()
|
||||
if not title:
|
||||
continue
|
||||
doi = _doi(row["DOI"] or "")
|
||||
article = {
|
||||
"title": title,
|
||||
"year": year,
|
||||
"doi": doi,
|
||||
"doi_url": f"https://doi.org/{quote(doi, safe='/')}" if DOI.fullmatch(doi) else None,
|
||||
"sources": sources,
|
||||
"row_number": row_number,
|
||||
}
|
||||
for author in set(_name(value) for value in (row["Авторы НИУ ВШЭ"] or "").split(";")) - {""}:
|
||||
articles[author].append(article)
|
||||
return articles
|
||||
|
||||
|
||||
def _unique_articles(articles: list[dict], start: date, end: date) -> list[dict]:
|
||||
unique = {}
|
||||
for article in articles:
|
||||
if not start.year <= article["year"] <= end.year:
|
||||
continue
|
||||
key = article["doi"] or (_name(article["title"]), article["year"])
|
||||
if key not in unique:
|
||||
unique[key] = {**article, "sources": list(article["sources"])}
|
||||
else:
|
||||
known = {item["name"] for item in unique[key]["sources"]}
|
||||
unique[key]["sources"].extend(
|
||||
item for item in article["sources"] if item["name"] not in known
|
||||
)
|
||||
return sorted(unique.values(), key=lambda item: (-len(item["sources"]), -item["year"], item["title"].casefold()))
|
||||
|
||||
|
||||
def council_candidates(db: Session, today: date | None = None) -> dict:
|
||||
today = today or datetime.now(ZoneInfo("Europe/Moscow")).date()
|
||||
start, end = period(today)
|
||||
employees = db.scalars(select(Employee).where(Employee.status == "active")).all()
|
||||
name_counts = Counter(_name(employee.full_name or "") for employee in employees)
|
||||
by_author = _articles_by_author()
|
||||
candidates = []
|
||||
for employee in employees:
|
||||
author = _name(employee.full_name or "")
|
||||
if not author or name_counts[author] != 1:
|
||||
continue
|
||||
try:
|
||||
degrees = academic_degrees(employee.current_data if isinstance(employee.current_data, dict) else {})
|
||||
except (AttributeError, TypeError):
|
||||
continue
|
||||
degree = next((value for value in degrees if DOCTOR.fullmatch(value)), None)
|
||||
if not degree:
|
||||
continue
|
||||
articles = _unique_articles(by_author.get(author, []), start, end)
|
||||
if len(articles) >= 10:
|
||||
candidates.append({"id": employee.id, "name": employee.full_name, "degree": degree,
|
||||
"count": len(articles), "articles": articles})
|
||||
candidates.sort(key=lambda item: (-item["count"], item["name"].casefold()))
|
||||
return {"candidates": candidates, "start": start, "end": end, "total": len(candidates)}
|
||||
+60
-10
@@ -2,7 +2,8 @@ import gzip
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import time
|
||||
import unicodedata
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import requests
|
||||
@@ -398,8 +399,11 @@ def _employee_publications_table_exists(db: Session) -> bool:
|
||||
return inspect(db.connection()).has_table(EmployeePublication.__tablename__)
|
||||
|
||||
|
||||
def _sync_employee_publications(db: Session, employee: Employee, parsed: dict) -> None:
|
||||
publications = _publication_payloads(parsed)
|
||||
def _sync_employee_publications(db: Session, employee: Employee, parsed: dict) -> None:
|
||||
publications = _publication_payloads(parsed)
|
||||
imported = [item for item in db.scalars(select(EmployeePublication).where(
|
||||
EmployeePublication.employee_id == employee.id
|
||||
)).all() if _csv_enrichment(item) is not None]
|
||||
seen_hashes = set()
|
||||
for publication in publications:
|
||||
source_hash = _publication_hash(publication)
|
||||
@@ -420,8 +424,10 @@ def _sync_employee_publications(db: Session, employee: Employee, parsed: dict) -
|
||||
EmployeePublication.source_hash == source_hash,
|
||||
)
|
||||
)
|
||||
if not existing:
|
||||
existing = EmployeePublication(employee_id=employee.id, source_hash=source_hash, title=_publication_title(publication))
|
||||
if not existing:
|
||||
existing = _match_imported_publication(imported, publication)
|
||||
if not existing:
|
||||
existing = EmployeePublication(employee_id=employee.id, source_hash=source_hash, title=_publication_title(publication))
|
||||
db.add(existing)
|
||||
_apply_publication(existing, publication, source_hash)
|
||||
|
||||
@@ -432,8 +438,39 @@ def _sync_employee_publications(db: Session, employee: Employee, parsed: dict) -
|
||||
EmployeePublication.source_hash.not_in(seen_hashes),
|
||||
)
|
||||
).all()
|
||||
for item in stale:
|
||||
db.delete(item)
|
||||
for item in stale:
|
||||
if _csv_enrichment(item) is None:
|
||||
db.delete(item)
|
||||
|
||||
|
||||
def _csv_enrichment(publication: EmployeePublication) -> dict | None:
|
||||
raw = publication.raw_data
|
||||
metadata = raw.get("csv_enrichment") if isinstance(raw, dict) else None
|
||||
return metadata if isinstance(metadata, dict) else None
|
||||
|
||||
|
||||
def _match_imported_publication(imported: list[EmployeePublication], publication: dict) -> EmployeePublication | None:
|
||||
def doi(value):
|
||||
return re.sub(r"^(?:https?://(?:dx\.)?doi\.org/|doi:\s*)", "", (value or "").strip(), flags=re.I).lower()
|
||||
|
||||
def title(value):
|
||||
return " ".join(unicodedata.normalize("NFKC", value or "").casefold().replace("ё", "е").split())
|
||||
|
||||
identifier = doi(publication.get("doi_url"))
|
||||
year = _int_or_none(publication.get("year"))
|
||||
name = title(publication.get("title") or publication.get("text"))
|
||||
matches = [item for item in imported if
|
||||
(identifier and doi(item.doi_url) == identifier) or
|
||||
(name and title(item.title) == name and item.year in (None, year))]
|
||||
if len(matches) > 1:
|
||||
raise ValueError("Ambiguous imported publication; synchronization rolled back")
|
||||
if not matches:
|
||||
return None
|
||||
match = matches[0]
|
||||
if ((identifier and doi(match.doi_url) and identifier != doi(match.doi_url)) or
|
||||
(year and match.year and year != match.year)):
|
||||
raise ValueError("Conflicting imported publication; synchronization rolled back")
|
||||
return match
|
||||
|
||||
|
||||
def _publication_payloads(parsed: dict) -> list[dict]:
|
||||
@@ -447,8 +484,16 @@ def _publication_payloads(parsed: dict) -> list[dict]:
|
||||
return publications
|
||||
|
||||
|
||||
def _apply_publication(target: EmployeePublication, publication: dict, source_hash: str) -> None:
|
||||
target.publication_id = _clean_optional(publication.get("publication_id") or publication.get("id"))
|
||||
def _apply_publication(target: EmployeePublication, publication: dict, source_hash: str) -> None:
|
||||
enrichment = _csv_enrichment(target)
|
||||
publication_id = _clean_optional(publication.get("publication_id") or publication.get("id"))
|
||||
if enrichment is not None and target.publication_id and publication_id and target.publication_id != publication_id:
|
||||
raise ValueError(
|
||||
f"Ambiguous HSE IDs for CSV publication {target.id}: "
|
||||
f"{target.publication_id} != {publication_id}; publication synchronization rolled back"
|
||||
)
|
||||
preserved = {field: getattr(target, field) for field in ("title", "year", "doi_url")}
|
||||
target.publication_id = (publication_id or target.publication_id) if enrichment is not None else publication_id
|
||||
target.title = _publication_title(publication)
|
||||
target.year = _int_or_none(publication.get("year"))
|
||||
target.publication_type = _clean_optional(publication.get("publication_type") or publication.get("type"))
|
||||
@@ -462,7 +507,12 @@ def _apply_publication(target: EmployeePublication, publication: dict, source_ha
|
||||
target.annotation = publication.get("annotation") if isinstance(publication.get("annotation"), dict) else None
|
||||
target.description = publication.get("description") if isinstance(publication.get("description"), dict) else None
|
||||
target.authors = publication.get("authors") if isinstance(publication.get("authors"), list) else None
|
||||
target.raw_data = publication.get("raw_data") if isinstance(publication.get("raw_data"), dict) else publication
|
||||
target.raw_data = publication.get("raw_data") if isinstance(publication.get("raw_data"), dict) else publication
|
||||
if enrichment is not None:
|
||||
target.raw_data = {**target.raw_data, "csv_enrichment": enrichment}
|
||||
for field in ("title", "year", "doi_url"):
|
||||
if not getattr(target, field) or (field == "title" and not (publication.get("title") or publication.get("text"))):
|
||||
setattr(target, field, preserved[field])
|
||||
target.source_hash = source_hash
|
||||
|
||||
|
||||
|
||||
+106
-29
@@ -8,20 +8,19 @@
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
.admin__skip-link {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
z-index: 100;
|
||||
padding: 8px 12px;
|
||||
color: #ffffff;
|
||||
background: #0f766e;
|
||||
transform: translateY(-150%);
|
||||
}
|
||||
|
||||
.admin__skip-link:focus {
|
||||
transform: translateY(0);
|
||||
}
|
||||
.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;
|
||||
@@ -184,6 +183,7 @@
|
||||
}
|
||||
|
||||
.button {
|
||||
box-sizing: border-box;
|
||||
min-height: 40px;
|
||||
padding: 10px 14px;
|
||||
border: 0;
|
||||
@@ -544,9 +544,9 @@
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.directory__filters {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 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;
|
||||
@@ -554,8 +554,8 @@
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.directory__filter-group {
|
||||
display: grid;
|
||||
.directory__filter-group {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
@@ -586,7 +586,9 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.directory__input {
|
||||
.directory__input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
@@ -776,7 +778,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
@media (max-width: 620px) {
|
||||
.directory-table [data-column="full_name"] {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
@@ -804,13 +806,88 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.directory__filter-group,
|
||||
.directory__filter-group--search,
|
||||
.directory__filter-actions {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.directory__field {
|
||||
.directory__filter-group,
|
||||
.directory__filter-group--search,
|
||||
.directory__filter-actions {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.directory__field {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.council {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
color: #25313a;
|
||||
}
|
||||
|
||||
.council__hero {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 32px;
|
||||
padding: clamp(24px, 4vw, 48px);
|
||||
color: #f9f6f0;
|
||||
background: #263640;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.council__hero-copy { max-width: 680px; }
|
||||
.council__title { margin: 0; font-family: Georgia, serif; font-size: clamp(29px, 4vw, 48px); line-height: 1.12; letter-spacing: -0.025em; }
|
||||
.council__intro { max-width: 60ch; margin: 16px 0 0; color: #e3e8e5; line-height: 1.55; }
|
||||
.council__total { margin: 0; min-width: 160px; color: #e3e8e5; font-size: 14px; line-height: 1.4; }
|
||||
.council__total strong { display: block; color: #f6d49b; font-size: 42px; font-variant-numeric: tabular-nums; line-height: 1; }
|
||||
|
||||
.council__criteria {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
padding-block: 6px 22px;
|
||||
border-bottom: 1px solid #cdd4d5;
|
||||
}
|
||||
|
||||
.council__criteria p { display: grid; align-content: start; gap: 5px; margin: 0; }
|
||||
.council__criteria strong { font-size: 18px; font-variant-numeric: tabular-nums; }
|
||||
.council__criteria span { color: #4c5b64; font-size: 13px; }
|
||||
.council__notice { padding: 16px 20px; background: #f6efe2; border-radius: 10px; line-height: 1.55; }
|
||||
.council__notice strong { color: #593b20; }
|
||||
.council__list-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
|
||||
.council__list-heading h3 { margin: 0; font-family: Georgia, serif; font-size: 27px; }
|
||||
.council__list-heading p { margin: 0; color: #4c5b64; font-size: 14px; }
|
||||
.council__list { display: grid; gap: 10px; }
|
||||
|
||||
.council__candidate { overflow: hidden; background: #fff; border: 1px solid #d6dddc; border-radius: 12px; }
|
||||
.council__candidate[open] { border-color: #8fa8a5; }
|
||||
.council__candidate-summary { display: grid; grid-template-columns: minmax(0, 1fr) auto 18px; align-items: center; gap: 20px; min-height: 76px; padding: 14px 20px; cursor: pointer; list-style: none; }
|
||||
.council__candidate-summary::-webkit-details-marker { display: none; }
|
||||
.council__candidate-summary:hover { background: #f3f7f5; }
|
||||
.council__candidate-identity { display: grid; gap: 4px; min-width: 0; }
|
||||
.council__candidate-identity strong { font-size: 17px; overflow-wrap: anywhere; }
|
||||
.council__candidate-identity small, .council__candidate-count small { color: #4c5b64; font-size: 12px; }
|
||||
.council__candidate-count { display: grid; gap: 2px; text-align: end; }
|
||||
.council__candidate-count strong { font-size: 25px; font-variant-numeric: tabular-nums; line-height: 1; }
|
||||
.council__candidate-cue { width: 9px; height: 9px; border-inline-end: 2px solid #345e59; border-bottom: 2px solid #345e59; transform: rotate(45deg); }
|
||||
.council__candidate[open] .council__candidate-cue { transform: rotate(225deg); }
|
||||
.council__candidate-body { padding: 20px; border-top: 1px solid #e4e9e8; }
|
||||
.council__articles { display: grid; gap: 0; margin: 16px 0 0; padding-inline-start: 22px; }
|
||||
.council__article { padding: 14px 4px; border-top: 1px solid #e7ebea; }
|
||||
.council__article::marker { color: #5e746f; font-variant-numeric: tabular-nums; }
|
||||
.council__article-heading { display: flex; justify-content: space-between; gap: 16px; line-height: 1.45; }
|
||||
.council__article-heading strong { font-weight: 600; overflow-wrap: anywhere; }
|
||||
.council__article-heading span { color: #4c5b64; font-variant-numeric: tabular-nums; }
|
||||
.council__article-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 12px; margin-top: 9px; color: #4c5b64; font-size: 12px; }
|
||||
.council__source { padding: 4px 8px; color: #244b46; background: #e7f0eb; border-radius: 4px; font-weight: 700; overflow-wrap: anywhere; }
|
||||
.council__message { padding: 28px; background: #fff; border-radius: 12px; }
|
||||
.council__message h3, .council__message h4 { margin: 0 0 8px; font-size: 19px; }
|
||||
.council__message p { margin: 0; color: #4c5b64; line-height: 1.5; }
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.council__hero, .council__list-heading { align-items: start; flex-direction: column; }
|
||||
.council__total strong { display: inline; margin-inline-end: 8px; font-size: 28px; }
|
||||
.council__criteria { grid-template-columns: 1fr; gap: 14px; }
|
||||
.council__candidate-summary { grid-template-columns: minmax(0, 1fr) auto 14px; gap: 10px; padding: 14px; }
|
||||
.council__candidate-count small { max-width: 72px; }
|
||||
.council__candidate-body { padding: 14px; }
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
<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>
|
||||
@@ -13,6 +13,7 @@
|
||||
<nav class="admin__nav">
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Кандидаты в диссертационный совет · MIEM Employees{% endblock %}
|
||||
{% block content %}
|
||||
<section class="council">
|
||||
<header class="council__hero">
|
||||
<div class="council__hero-copy">
|
||||
<h2 class="council__title">Кандидаты в диссертационный совет</h2>
|
||||
<p class="council__intro">Сотрудники с докторской степенью в профиле и публикациями с отметками Q1 или Q2 в имеющейся выгрузке. Список помогает начать проверку состава комиссии.</p>
|
||||
</div>
|
||||
{% if not error %}<p class="council__total"><strong>{{ total }}</strong> предварительно подходят</p>{% endif %}
|
||||
</header>
|
||||
|
||||
{% if error %}
|
||||
<div class="council__message" role="alert">
|
||||
<h3>Не удалось загрузить список</h3>
|
||||
<p>Проверьте доступность базы сотрудников и файла публикаций, затем обновите страницу.</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="council__criteria" aria-label="Правила предварительного отбора">
|
||||
<p><strong>10 и более</strong><span>различных публикаций</span></p>
|
||||
<p><strong>{{ start.strftime('%d.%m.%Y') }} — {{ end.strftime('%d.%m.%Y') }}</strong><span>скользящие пять лет</span></p>
|
||||
<p><strong>К1 · RSCI · Q1/Q2</strong><span>итоговые основания отбора</span></p>
|
||||
</div>
|
||||
<div class="council__notice" role="note">
|
||||
<strong>Предварительный список.</strong> Сейчас учитываются только отметки Q1/Q2 в CSV для Scopus и Web of Science. В нём нет названия журнала, точной даты статьи, типа материала и подтверждения К1 или RSCI. Год на границе периода и принадлежность к журналу нужно проверить. Сотрудники с публикациями только в К1 или RSCI могут отсутствовать.
|
||||
</div>
|
||||
|
||||
<div class="council__list-heading">
|
||||
<h3>Сотрудники</h3>
|
||||
<p>Откройте строку, чтобы увидеть публикации и основания.</p>
|
||||
</div>
|
||||
{% if candidates %}
|
||||
<div class="council__list">
|
||||
{% for candidate in candidates %}
|
||||
<details class="council__candidate">
|
||||
<summary class="council__candidate-summary">
|
||||
<span class="council__candidate-identity"><strong>{{ candidate.name }}</strong><small>{{ candidate.degree }}</small></span>
|
||||
<span class="council__candidate-count"><strong>{{ candidate.count }}</strong><small>публикаций по выгрузке</small></span>
|
||||
<span class="council__candidate-cue" aria-hidden="true"></span>
|
||||
</summary>
|
||||
<div class="council__candidate-body">
|
||||
<a class="admin__link" href="/admin/employees/{{ candidate.id }}">Открыть профиль сотрудника</a>
|
||||
<ol class="council__articles">
|
||||
{% for article in candidate.articles %}
|
||||
<li class="council__article">
|
||||
<div class="council__article-heading">
|
||||
<strong>{{ article.title }}</strong>
|
||||
<span>{{ article.year }}</span>
|
||||
</div>
|
||||
<div class="council__article-meta">
|
||||
{% for source in article.sources %}
|
||||
<span class="council__source">{{ source.name }} {{ source.quartile }}{% if source.identifier %} · {{ source.identifier }}{% else %} · ID не указан{% endif %}</span>
|
||||
{% endfor %}
|
||||
{% if article.doi_url %}<a class="admin__link" href="{{ article.doi_url }}" target="_blank" rel="noopener noreferrer">DOI</a>{% endif %}
|
||||
<span>Строка {{ article.row_number }} в CSV</span>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</div>
|
||||
</details>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="council__message">
|
||||
<h4>Кандидаты пока не найдены</h4>
|
||||
<p>По доступной выгрузке нет действующих сотрудников с докторской степенью, указанной в профиле, и десятью предварительно подходящими публикациями. Это не означает, что таких сотрудников нет.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -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">
|
||||
|
||||
+3
-3
@@ -1,3 +1,3 @@
|
||||
APP_VERSION = "0.8.2"
|
||||
FRONTEND_VERSION = "0.8.2"
|
||||
BACKEND_VERSION = "0.8.2"
|
||||
APP_VERSION = "0.10.4"
|
||||
FRONTEND_VERSION = "0.10.4"
|
||||
BACKEND_VERSION = "0.10.4"
|
||||
@@ -1,6 +1,7 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-miem_workers}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-miem}
|
||||
@@ -15,6 +16,7 @@ services:
|
||||
|
||||
api:
|
||||
build: .
|
||||
restart: always
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
env_file: .env
|
||||
environment:
|
||||
@@ -27,6 +29,7 @@ services:
|
||||
|
||||
worker:
|
||||
build: .
|
||||
restart: always
|
||||
command: python -m app.worker
|
||||
env_file: .env
|
||||
environment:
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# Проверка импорта на изолированном PostgreSQL
|
||||
|
||||
Запускается средствами проекта: pytest, SQLAlchemy, psycopg и отдельный Docker
|
||||
Compose. Не использует production Compose, `.env`, SSH или данные сотрудников.
|
||||
PostgreSQL 16 и runner работают в отдельной `internal`-сети без опубликованных
|
||||
портов. Данные PostgreSQL находятся в tmpfs, без постоянных volumes. Базы:
|
||||
`miem_import_test` и `miem_crawl_test`. Все сотрудники, статьи и credentials
|
||||
синтетические; тестовые пароли непригодны для реальных систем.
|
||||
|
||||
Dockerfile копирует только нужный код и requirements. Его отдельный dockerignore
|
||||
исключает `.git`, `.env`, файлы ключей/credentials, CSV владельца, данные
|
||||
`app/data` и остальные файлы workspace. Runtime не получает переменные хоста.
|
||||
CLI subprocess дополнительно получает только явно заданные тестовые libpq
|
||||
параметры; `.pgpass`/service-файлы отключены. Не изменяйте адрес `postgres-test`
|
||||
или имена тестовых БД для подключения к существующему серверу.
|
||||
|
||||
Синтетическая роль `import_test` создаётся только init-скриптом одноразового
|
||||
тестового кластера. Fixture выдаёт ей документированные column grants и USAGE
|
||||
sequence. Проверяется отсутствие CREATE схемы, INSERT сотрудников, DELETE
|
||||
публикаций и sequence UPDATE. Это не создание временного доступа к рабочей БД.
|
||||
Таблицы создаются моделью приложения и очищаются только в тестовых БД.
|
||||
|
||||
HTTP заблокирован test fixture; входные профили полного обхода подставляются
|
||||
локально. Это проверяет настоящий SQL, транзакции, savepoint и сериализацию JSON,
|
||||
но не парсинг сайта ВШЭ. Fixtures SQLite и подмена psycopg для интеграционных
|
||||
тестов не используются.
|
||||
|
||||
## Что проверяется
|
||||
|
||||
1. Dry-run: все строки всех таблиц и состояния sequences совпадают до/после.
|
||||
2. Apply: одна вставка, одно обновление, пропуск отсутствующего сотрудника.
|
||||
3. Повторный импорт: ноль INSERT/UPDATE, неизменные таблицы/sequences.
|
||||
4. CHECK constraint тестовой БД вызывает ошибку третьего действия после UPDATE
|
||||
и успешного INSERT; все строки возвращаются в исходное состояние. Sequence
|
||||
может продвинуться при rollback — это ожидаемое поведение PostgreSQL.
|
||||
5. Обновление только года без raw_data сохраняет существующий JSONB.
|
||||
6. Поздние строки CSV дополняют метаданные; первое непустое значение сохраняется
|
||||
при конфликте, отчёт содержит названия конфликтующих полей. Повтор идемпотентен.
|
||||
7. Два последовательных crawl сохраняют CSV-статью, отсутствующую у ВШЭ;
|
||||
появившаяся статья получает HSE ID без второй записи и потери метаданных.
|
||||
8. Полный `run_crawl` обрабатывает двух сотрудников. Два HSE ID на одну CSV-запись
|
||||
откатывают savepoint первого, сохраняя JSONB, авторов, ID, хеш и updated_at;
|
||||
публикация второго сохраняется. Проверяются обе очередности ID и уже
|
||||
установленная привязка; неоднозначность отражается в CrawlError.
|
||||
|
||||
Всего 10 PostgreSQL-тестов (восьмой сценарий параметризован). В обычном
|
||||
`python -m pytest -q` они пропускаются. Opt-in `MIEM_POSTGRES_INTEGRATION=1`
|
||||
разрешён только с точным тестовым DATABASE_URL Compose-проекта.
|
||||
|
||||
## Команды владельцу
|
||||
|
||||
Выполняйте на локальной машине с Docker Engine/Compose v2 из корня репозитория
|
||||
после независимого ревью. Команды явно выбирают локальный Unix socket и очищают
|
||||
наследуемое окружение. Для Docker Desktop/Windows используйте Linux/WSL с
|
||||
доступным локальным `/var/run/docker.sock`. Для отдельного стенда на VPS используйте
|
||||
[отдельную инструкцию](postgresql-import-vps.md) и `compose.vps.yml`: этот локальный
|
||||
Compose не предназначен для запуска на сервере с работающими сервисами.
|
||||
Первый шаг:
|
||||
|
||||
```sh
|
||||
env -i PATH="$PATH" docker --host unix:///var/run/docker.sock version
|
||||
```
|
||||
|
||||
Ожидается exit code 0 и секции `Client` и `Server`. `command not found`,
|
||||
`permission denied` или только Client с ошибкой подключения означает, что
|
||||
до запуска этого варианта нужен локальный Docker. При его отсутствии сначала
|
||||
выполните только read-only диагностику из инструкции VPS.
|
||||
|
||||
Дальнейшие точные команды сохранены здесь для воспроизводимого запуска:
|
||||
|
||||
```sh
|
||||
env -i PATH="$PATH" docker --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration -f tests/postgres/compose.yml config --quiet
|
||||
env -i PATH="$PATH" docker --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration -f tests/postgres/compose.yml build tests
|
||||
env -i PATH="$PATH" docker --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration -f tests/postgres/compose.yml up --abort-on-container-exit --exit-code-from tests
|
||||
```
|
||||
|
||||
Первые две команды проверяют Compose и собирают runner с уже используемыми
|
||||
зависимостями. Сборка/скачивание образов требуют registry/PyPI, но тестовая
|
||||
runtime-сеть не имеет внешнего доступа и запросов к ВШЭ нет. При успехе третьей
|
||||
команды ожидаются `10 passed` и exit code 0. Фактическую совместимость образа,
|
||||
column grants и PostgreSQL можно считать подтверждённой **только после такого
|
||||
выполнения**, не по unit-тестам или статическому чтению файлов.
|
||||
|
||||
После сохранения вывода тестов удалите именно этот тестовый проект:
|
||||
|
||||
```sh
|
||||
env -i PATH="$PATH" docker --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration -f tests/postgres/compose.yml down
|
||||
```
|
||||
|
||||
Это уничтожит только синтетические данные в tmpfs тестового PostgreSQL. Не
|
||||
используйте `docker system prune`, production Compose или удаление рабочих
|
||||
volumes. Перед повторным прогоном выполните этот же `down`, чтобы следующий
|
||||
кластер начинался с чистого init.sql; не запускайте два прогона одновременно.
|
||||
|
||||
## Статус подготовки
|
||||
|
||||
В среде подготовки Docker отсутствует; настоящие PostgreSQL-тесты не запускались.
|
||||
Локально доступны только unit-тесты, проверка Python и статические проверки.
|
||||
Production/VPS и доступ к рабочей БД не изменялись.
|
||||
@@ -0,0 +1,153 @@
|
||||
# PostgreSQL-проверка на отдельном стенде VPS
|
||||
|
||||
Команды выполняет владелец под root. Подготовка не подключается к VPS и не
|
||||
запускает стенд. Не используйте production Compose/`.env`, Docker context,
|
||||
volumes, сети, mounts или production credentials. Никаких production-ролей БД,
|
||||
SSH-ключей и правил firewall эти команды не создают и не изменяют.
|
||||
|
||||
## Сначала только read-only проверка
|
||||
|
||||
Следующий блок не создаёт контейнеров, не скачивает образов и не меняет данные.
|
||||
Docker CLI использует локальный Unix socket и очищенное окружение; конфигурация
|
||||
CLI указывает на отдельный каталог, который здесь не создаётся.
|
||||
|
||||
```sh
|
||||
free -h
|
||||
nproc
|
||||
uptime
|
||||
df -h / /var/lib/docker
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock info --format 'CPUs={{.NCPU}} RAM_bytes={{.MemTotal}} cgroup={{.CgroupVersion}} DockerRootDir={{.DockerRootDir}} warnings={{json .Warnings}}'
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock compose version
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock stats --no-stream --format 'table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.PIDs}}'
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock image inspect postgres:16-alpine python:3.12-slim miem-publication-integration-postgres:pr55 miem-publication-integration-runner:pr55 --format '{{.RepoTags}} id={{.Id}} revision={{index .Config.Labels "org.opencontainers.image.revision"}}'
|
||||
```
|
||||
|
||||
Как читать результат:
|
||||
|
||||
- `free`: нужна колонка **available**, а не только free. Предварительный запас —
|
||||
не менее 2 GiB: стенд ограничен суммарно 1 GiB, ещё минимум 1 GiB остаётся
|
||||
системе/сервисам. Это ориентир, решение зависит от их нагрузки и пиков.
|
||||
- `nproc` и `uptime`: сравните число CPU и load average. Постоянный load на уровне
|
||||
числа CPU или выше — основание отложить тест, а не останавливать сервисы.
|
||||
- `df`: желательно не менее 4 GiB свободно для загрузки образов и запаса работы
|
||||
Docker. Если DockerRootDir отличается от `/var/lib/docker`, отдельно выполните
|
||||
`df -h` для показанного каталога. Не используйте prune для освобождения места.
|
||||
- `stats`: показывает потребление уже работающих контейнеров, но не их пиковые
|
||||
требования. Docker warnings о недоступных memory/swap/cgroup limits нужно
|
||||
устранить до запуска; без действующих лимитов этот вариант не запускать.
|
||||
- `image inspect`: существующие образы показывают tags, ID и revision.
|
||||
`No such image` означает отсутствие образа, **не** разрешение на pull/build.
|
||||
Для запуска нужны два образа `miem-publication-integration-*:pr55` с revision
|
||||
проверенного commit PR #55. Base-образы нужны только для сборки, не для самого
|
||||
запуска уже подготовленного стенда.
|
||||
|
||||
Пришлите только этот диагностический вывод. Не присылайте `docker inspect`
|
||||
production-контейнеров целиком, env, конфигурации подключения или секреты.
|
||||
До оценки ресурсов и завершения независимого review остальные действия ниже
|
||||
не выполнять.
|
||||
|
||||
## Изоляция и лимиты подготовленного стенда
|
||||
|
||||
Используется только `tests/postgres/compose.vps.yml`, не override production или
|
||||
локального Compose. Project name фиксирован:
|
||||
`miem-publication-integration-vps`.
|
||||
|
||||
| Ресурс | Лимит / область |
|
||||
|---|---|
|
||||
| PostgreSQL | Отдельный контейнер `postgres-test`, 0.5 CPU, 512 MiB RAM, без дополнительного swap, 128 процессов |
|
||||
| PostgreSQL настройки | shared_buffers 64 MiB, max_connections 12 |
|
||||
| Test runner | 0.5 CPU, 512 MiB RAM, без дополнительного swap, 128 процессов; UID 65534, read-only rootfs, no-new-privileges, без capabilities |
|
||||
| Данные PostgreSQL | Только named volume `miem-publication-integration-vps_pgdata`; local driver tmpfs до 256 MiB |
|
||||
| Временные файлы runner | tmpfs `/tmp` до 128 MiB; входят в memory limit контейнера |
|
||||
| Сеть | Только `miem-publication-integration-vps_isolated`, internal; опубликованных портов нет |
|
||||
| Логи | json-file, до 5 MiB на контейнер, один файл |
|
||||
|
||||
Volume с tmpfs выбран намеренно: отдельное имя для адресной очистки и предел
|
||||
размера синтетических данных. Данные не сохраняются после остановки/размонтирования.
|
||||
Образы содержат только код и синтетический init.sql. Host bind mounts,
|
||||
Docker socket, production networks и env_file отсутствуют. Docker socket
|
||||
доступен только CLI владельца на хосте, ни одному контейнеру он не передаётся.
|
||||
Нет restart policy. Pull запрещён; ресурсы не создаются автоматически при
|
||||
отсутствии заранее подготовленного образа.
|
||||
|
||||
Лимиты процесса сборки/загрузки образов отличаются от runtime: Compose CPU/RAM
|
||||
limits **не ограничивают Docker build**. Поэтому на VPS с рабочими сервисами
|
||||
не выполняйте незапланированную сборку. Если готовых образов нет, отдельно
|
||||
согласуйте место сборки и окно загрузки после read-only диагностики.
|
||||
|
||||
## Подготовка образов после review (не запуск стенда)
|
||||
|
||||
На отдельной согласованной машине сборки с Docker, из проверенной копии PR #55,
|
||||
укажите полный SHA проверенного commit в переменной `TEST_COMMIT` (не секрет).
|
||||
Сначала `git status --short` должен быть пустым, `git rev-parse HEAD` — совпадать
|
||||
с TEST_COMMIT. Это не команды для немедленного выполнения на VPS.
|
||||
|
||||
```sh
|
||||
git status --short
|
||||
git rev-parse HEAD
|
||||
env -i PATH="$PATH" docker --host unix:///var/run/docker.sock build --label org.opencontainers.image.revision="$TEST_COMMIT" -t miem-publication-integration-runner:pr55 -f tests/postgres/Dockerfile .
|
||||
env -i PATH="$PATH" docker --host unix:///var/run/docker.sock build --label org.opencontainers.image.revision="$TEST_COMMIT" -t miem-publication-integration-postgres:pr55 -f tests/postgres/Dockerfile.postgres tests/postgres
|
||||
env -i PATH="$PATH" docker --host unix:///var/run/docker.sock image save --output miem-publication-integration-images.tar miem-publication-integration-postgres:pr55 miem-publication-integration-runner:pr55
|
||||
sha256sum miem-publication-integration-images.tar tests/postgres/compose.vps.yml
|
||||
```
|
||||
|
||||
Dockerfile-specific ignore-файлы исключают секреты, реальные CSV и остальные
|
||||
файлы workspace из обоих контекстов сборки. Передайте владельцу только архив
|
||||
этих двух образов и `compose.vps.yml` с проверенными SHA256, без полного workspace
|
||||
и `.env`. Транспорт выбирает владелец; инструкция не получает SSH/production
|
||||
credentials. Base-образы могут скачиваться только на согласованной машине сборки.
|
||||
|
||||
На VPS после оценки ресурсов/отдельного разрешения владельца: файлы размещаются
|
||||
в `/root/miem-publication-integration`, Compose переименован в `compose.vps.yml`,
|
||||
архив — в `images.tar`. Проверка checksum должна совпасть с источником.
|
||||
|
||||
```sh
|
||||
install -d -m 700 /root/miem-publication-integration /root/miem-publication-integration/docker-config
|
||||
sha256sum /root/miem-publication-integration/images.tar /root/miem-publication-integration/compose.vps.yml
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock image load --input /root/miem-publication-integration/images.tar
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock image inspect miem-publication-integration-postgres:pr55 miem-publication-integration-runner:pr55 --format '{{.RepoTags}} id={{.Id}} revision={{index .Config.Labels "org.opencontainers.image.revision"}}'
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration-vps -f /root/miem-publication-integration/compose.vps.yml config --quiet
|
||||
```
|
||||
|
||||
Проверьте отсутствие уже существующих ресурсов с этим project name. Если они
|
||||
есть, выясните их назначение; не запускайте второй стенд и не удаляйте чужие
|
||||
ресурсы. Следующие команды только читают labels Docker Compose:
|
||||
|
||||
```sh
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock ps -a --filter label=com.docker.compose.project=miem-publication-integration-vps
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock network ls --filter label=com.docker.compose.project=miem-publication-integration-vps
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock volume ls --filter label=com.docker.compose.project=miem-publication-integration-vps
|
||||
```
|
||||
|
||||
## Запуск и адресная очистка (пока не выполнять)
|
||||
|
||||
Только после согласования владельцем ресурсов и проверенного состава стенда:
|
||||
|
||||
```sh
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration-vps -f /root/miem-publication-integration/compose.vps.yml up --no-build --pull never --abort-on-container-exit --exit-code-from tests
|
||||
```
|
||||
|
||||
Ожидаемый успех — `10 passed`, exit code 0. Сохраните вывод/код выхода и логи:
|
||||
|
||||
```sh
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration-vps -f /root/miem-publication-integration/compose.vps.yml logs --no-color > /root/miem-publication-integration/test.log
|
||||
```
|
||||
|
||||
**Следующая команда удаляет контейнеры, внутреннюю сеть и named volume именно
|
||||
этого теста; все его синтетические данные будут потеряны.** Перед ней сохраните
|
||||
отчёт. Она не затрагивает ресурсы других Compose-проектов:
|
||||
|
||||
```sh
|
||||
env -i PATH="$PATH" docker --config /root/miem-publication-integration/docker-config --host unix:///var/run/docker.sock compose --env-file /dev/null --project-name miem-publication-integration-vps -f /root/miem-publication-integration/compose.vps.yml down --volumes
|
||||
```
|
||||
|
||||
Не используйте `--remove-orphans`, `docker system prune`, общие rm/rmi/volume prune
|
||||
или down рабочего проекта. Base-образы и образы теста оставьте: они могут быть
|
||||
общими для других проверок. Архив и журнал также не удаляются этой командой.
|
||||
Для подтверждения очистки повторите три read-only команды с project label:
|
||||
в выводе должны остаться только заголовки.
|
||||
|
||||
## Статус
|
||||
|
||||
VPS-стенд не запускался, Docker/Compose-проверка не выполнялась в среде
|
||||
подготовки из-за отсутствия Docker. PostgreSQL-интеграция ещё не подтверждена.
|
||||
@@ -0,0 +1,312 @@
|
||||
# Разовый импорт публикаций
|
||||
|
||||
Инструмент: `scripts/import_publications.py`. Он не импортирует приложение, не читает
|
||||
`.env`, не вызывает `init_db`, не создаёт таблиц и не удаляет записи. Подключение
|
||||
берётся только из libpq environment/service, настроенных владельцем. Production в
|
||||
рамках подготовки не проверялся и не изменялся.
|
||||
|
||||
Локальные проверки: синтетические сотрудники, повторный запуск, дубликаты,
|
||||
неоднозначности, конфликты DOI/года, сохранение заполненных значений и
|
||||
транзакционный путь на тестовом соединении (read-only / rollback при второй
|
||||
ошибочной вставке / успешный commit). Прогон на настоящем PostgreSQL и проверка
|
||||
grants не выполнялись; первый owner-run dry-run с read-only ролью обязателен.
|
||||
|
||||
## Вход и сопоставление
|
||||
|
||||
Файл владельца `Статьи.csv`: UTF-8 с возможным BOM, разделитель `;`, 1797 записей,
|
||||
SHA256 `1f236be56d99677b1403df29a4c1d3a7e6d10cb90bd355d678201a027921b0ce`.
|
||||
Обязательные колонки: `Название публикации`, `Год`, `DOI`, `Авторы НИУ ВШЭ`.
|
||||
Номер строки в отчёте — номер CSV-записи с заголовком, а не физической строки.
|
||||
|
||||
Сотрудник сопоставляется по полному ФИО: Unicode NFKC, регистр, пробелы и ё/е.
|
||||
Учитываются все существующие статусы; новые сотрудники не создаются. Ноль
|
||||
совпадений → `employee_not_found`, несколько → `ambiguous_employee`. Инициалы,
|
||||
переводы, перестановки слов и приблизительные совпадения не используются.
|
||||
|
||||
Публикация сопоставляется **внутри конкретного сотрудника** по нормализованному DOI
|
||||
или полному названию и году. Отсутствующий год БД допускает заполнение при
|
||||
совпадении названия. Если эти способы указывают на разные записи, импорт
|
||||
пропускается (`ambiguous_publication`). Разные заполненные DOI или годы —
|
||||
`doi_conflict` / `year_conflict`. Существующие дубликаты не объединяются и не
|
||||
удаляются. Одна статья у разных сотрудников — предусмотренные моделью авторские
|
||||
связи, а не дубли внутри карточки.
|
||||
|
||||
Повтор CSV внутри сотрудника не создаёт записи. Каждая поздняя строка дополняет
|
||||
ещё пустые метаданные; первый непустой источник имеет приоритет, в том числе
|
||||
ранее сохранённое значение БД. Повтор без изменений — `csv_duplicate`, дополнение
|
||||
запланированной вставки — `merged_csv_duplicate`. При конфликте непустых
|
||||
метаданных первое значение сохраняется; остальные пустые поля всё равно
|
||||
дополняются. Отчёт содержит `metadata_conflict_first_value_kept`, список
|
||||
`conflict_fields` (только названия полей, без значений) и сводный счётчик
|
||||
`metadata_conflicts`. Конфликт DOI/года идентификации по-прежнему пропускает
|
||||
всю связь. Если строка без DOI была запланирована первой, следующая строка с
|
||||
DOI может дополнить ту же вставку. При повторном запуске уже заполненные записи дают
|
||||
`already_present`. Хеш новой записи детерминирован по сотруднику и DOI либо
|
||||
названию/году. `source_hash` существующих записей не меняется.
|
||||
|
||||
Пустые ячейки ничего не стирают. Пустые название/авторы, некорректный год
|
||||
(допустимо 1000–9999) или DOI пропускаются как `invalid_fields`. Повреждённая
|
||||
структура CSV прерывает весь запуск до подключения. Несовместимый JSON БД
|
||||
пропускается (`unsupported_raw_data`, `unsupported_csv_metadata`).
|
||||
|
||||
## Таблицы, столбцы и права
|
||||
|
||||
| Объект | Операция / столбцы |
|
||||
|---|---|
|
||||
| `public.employees` | Только SELECT: `id`, `full_name` |
|
||||
| `public.employee_publications` | SELECT: `id`, `employee_id`, `title`, `year`, `doi_url`, `raw_data` |
|
||||
| Новая публикация | INSERT: `employee_id`, `title`, `year`, `doi_url` при наличии, `raw_data`, `source_hash`, `created_at`, `updated_at` |
|
||||
| Существующая публикация | UPDATE: только пустые `title`, `year`, `doi_url`; `raw_data`; служебный `updated_at` |
|
||||
| Sequence идентификатора публикации | USAGE для `nextval`; имя определяется `pg_get_serial_sequence`, обычно `public.employee_publications_id_seq` |
|
||||
|
||||
Остальные таблицы и sequences не затрагиваются. В `raw_data` сохраняются прежние
|
||||
ключи; добавляется/дополняется `csv_enrichment` только отсутствующими или пустыми
|
||||
значениями. Уже заполненные CSV-метаданные не заменяются. Поля `authors`,
|
||||
`publication_id`, тип материала, язык, аннотация, ссылки HSE и статус не меняются.
|
||||
CSV содержит **только авторов НИУ ВШЭ**, поэтому он не считается полным составом
|
||||
авторов и не заменяет `authors`. Идентификаторы Scopus/WoS, квартиль и остальные
|
||||
непустые исходные ячейки сохраняются в `raw_data.csv_enrichment` без интерпретации.
|
||||
Карточки `employees.current_data` и snapshots не изменяются; отчёт и данные
|
||||
таблицы не означают обновления всех представлений интерфейса.
|
||||
|
||||
Минимальные собственные grants роли: CONNECT к БД, USAGE схемы public,
|
||||
перечисленные SELECT/INSERT/UPDATE по столбцам, USAGE найденной sequence.
|
||||
Не нужны DELETE, TRUNCATE, CREATE, ALTER, ownership, superuser, доступ к прочим
|
||||
таблицам и sequence UPDATE/setval. Роль новая, без членства в других ролях.
|
||||
Права PUBLIC всё равно наследуются: владелец должен отдельно проверить их;
|
||||
этот набор команд не меняет общие права PUBLIC и не гарантирует изоляцию при
|
||||
уже существующих широких общих grants. В частности TEMP может быть выдан PUBLIC.
|
||||
|
||||
## Применение и ограничения
|
||||
|
||||
Без `--apply` используется PostgreSQL `SERIALIZABLE READ ONLY`, без INSERT,
|
||||
UPDATE и nextval, с rollback. С `--apply` весь план применяется одной транзакцией
|
||||
SERIALIZABLE. Advisory lock предотвращает одновременное применение этим
|
||||
импортером. Таймаут запроса — 60 секунд, ожидания блокировок — 5 секунд.
|
||||
Ошибка записи/конфликт сериализации откатывает все изменения таблиц; автоматических
|
||||
повторов нет. Sequence может получить пропуски при откате — это нормальное
|
||||
поведение PostgreSQL. Изменение входного файла во время запуска прерывает импорт.
|
||||
|
||||
JSON-отчёт содержит количества insert/update/skip, причины и пары
|
||||
`номер CSV-записи — employee_id`, без ФИО, названий статей и секретов. Счётчики
|
||||
относятся к авторским связям, а не уникальным статьям. Консоль выводит только
|
||||
сводку. Отчёт сначала пишется в `.pending` до commit, после успеха переименовывается.
|
||||
Если остался `.pending`, следующий запуск блокируется: сначала проверьте исход
|
||||
транзакции. Отчёт `.pending` не доказывает commit; ошибка переименования после
|
||||
commit также возможна. Безопасный повторный dry-run позволяет проверить состояние.
|
||||
При необходимости владелец перемещает `.pending` в архив перед повтором.
|
||||
|
||||
**До применения нужен свежий backup БД.** Во время импорта владелец должен
|
||||
исключить конкурентные записи публикаций на время транзакции, согласовав окно
|
||||
работы; эта подготовка не отключает crawler. Не выполняйте production apply до
|
||||
независимого ревью и развёртывания исправления crawler из этого PR.
|
||||
|
||||
До исправления `_upsert_employee` запускал синхронизацию публикаций даже для
|
||||
неизменённой карточки. При пустом списке публикаций или отсутствии таблицы
|
||||
синхронизация пропускалась. При непустом списке `_sync_employee_publications`
|
||||
удалял все записи сотрудника, чей `source_hash` отсутствовал среди хешей ответа
|
||||
ВШЭ, включая CSV-вставки. Найденные по `publication_id`/хешу записи полностью
|
||||
получали новый `raw_data`, поэтому теряли CSV-метаданные даже без удаления.
|
||||
|
||||
Исправление сохраняет записи с dict `raw_data.csv_enrichment` при очистке
|
||||
устаревших публикаций. При обновлении сохраняется этот ключ и заполненные
|
||||
title/year/doi_url, если ВШЭ вернул пустые значения. Непустые значения ВШЭ
|
||||
продолжают обновлять канонические поля, а исходные CSV-значения остаются в
|
||||
метаданных. Если статья впервые появилась у ВШЭ, crawler сопоставляет её с
|
||||
импортированной записью по DOI или названию/году до новой вставки, сохраняет id
|
||||
и добавляет HSE publication_id. Конфликт/неоднозначность откатывает весь savepoint
|
||||
синхронизации публикаций и регистрирует CrawlError; карточка сотрудника продолжает
|
||||
обрабатываться. Удаление обычных устаревших HSE-записей остаётся прежним. Здесь
|
||||
не приведены команды рестарта или отключения сервисов.
|
||||
|
||||
Уже установленный HSE publication_id CSV-записи нельзя заменить другим HSE ID,
|
||||
даже когда DOI/название/год совпадают. Это проверяется перед изменением полей
|
||||
при любом пути сопоставления (ID, source_hash, CSV-поля). Поэтому две HSE-статьи
|
||||
не могут по очереди перезаписать одну CSV-запись: весь savepoint откатывается
|
||||
независимо от порядка статей. Отсутствующий ID во входе не очищает существующую
|
||||
CSV-привязку. Отчёт обхода (`errors` в деталях запуска) содержит CrawlError с
|
||||
идентификатором записи БД и конфликтующими HSE ID, без ФИО и названий статей.
|
||||
|
||||
## Команды владельцу (не выполнялись)
|
||||
|
||||
Ниже — подготовка разового доступа после ревью. Выполняет владелец на сервере.
|
||||
Рабочая директория — проверенная копия этого PR; файлы программы и CSV должны
|
||||
быть защищены от записи временным SSH-пользователем. Пароли/ключи не включать
|
||||
в репозиторий или чат. Имена контейнеров соответствуют текущему Compose-проекту;
|
||||
владелец проверяет их локально перед выполнением.
|
||||
|
||||
Сначала резервная копия в приватный каталог владельца:
|
||||
|
||||
```sh
|
||||
install -d -m 700 /var/backups/miem-publication-import
|
||||
docker exec miem_workers-postgres-1 pg_dump -U miem -d miem_workers -Fc -f /tmp/publications-before.dump
|
||||
docker cp miem_workers-postgres-1:/tmp/publications-before.dump /var/backups/miem-publication-import/before.dump
|
||||
chmod 600 /var/backups/miem-publication-import/before.dump
|
||||
```
|
||||
|
||||
При повторной операции используйте новый путь backup; не перезаписывайте нужную
|
||||
предыдущую копию. Проверьте exit codes и архив `pg_restore --list` до выдачи write.
|
||||
|
||||
Откройте psql владельцем БД:
|
||||
|
||||
```sh
|
||||
docker exec -it miem_workers-postgres-1 psql -U miem -d miem_workers
|
||||
```
|
||||
|
||||
Выполните SQL (без пароля в SQL/истории):
|
||||
|
||||
```sql
|
||||
\set ON_ERROR_STOP on
|
||||
SELECT format('CREATE ROLE miem_publication_import LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS CONNECTION LIMIT 1 VALID UNTIL %L', CURRENT_TIMESTAMP + interval '24 hours') \gexec
|
||||
\password miem_publication_import
|
||||
GRANT CONNECT ON DATABASE miem_workers TO miem_publication_import;
|
||||
GRANT USAGE ON SCHEMA public TO miem_publication_import;
|
||||
GRANT SELECT (id, full_name) ON public.employees TO miem_publication_import;
|
||||
GRANT SELECT (id, employee_id, title, year, doi_url, raw_data) ON public.employee_publications TO miem_publication_import;
|
||||
-- Сначала выдаются только read-права для dry-run.
|
||||
SELECT pg_get_serial_sequence('public.employee_publications', 'id');
|
||||
SELECT has_schema_privilege('miem_publication_import', 'public', 'CREATE') AS unexpected_create,
|
||||
has_table_privilege('miem_publication_import', 'public.employees', 'INSERT') AS unexpected_employee_insert,
|
||||
has_table_privilege('miem_publication_import', 'public.employee_publications', 'DELETE') AS unexpected_delete;
|
||||
```
|
||||
|
||||
Три проверки должны вернуть false. Не выдавайте write-права при неожиданном
|
||||
доступе. Если sequence не найдена, выясните фактический механизм id перед импортом.
|
||||
Срок роли ограничивает парольную авторизацию, но не заменяет отзыв доступа и
|
||||
завершение открытых сессий.
|
||||
|
||||
После принятого dry-run, непосредственно перед разрешённым apply:
|
||||
|
||||
```sql
|
||||
GRANT INSERT (employee_id, title, year, doi_url, raw_data, source_hash, created_at, updated_at) ON public.employee_publications TO miem_publication_import;
|
||||
GRANT UPDATE (title, year, doi_url, raw_data, updated_at) ON public.employee_publications TO miem_publication_import;
|
||||
SELECT format('GRANT USAGE ON SEQUENCE %s TO miem_publication_import', pg_get_serial_sequence('public.employee_publications', 'id')) \gexec
|
||||
```
|
||||
|
||||
Подготовьте внутри API-контейнера защищённые файлы libpq `pg_service.conf` и
|
||||
`pgpass` из окружения/секрет-хранилища владельца: service
|
||||
`miem_publication_import`, БД `miem_workers`, пользователь
|
||||
`miem_publication_import`. Используйте внутренний PostgreSQL endpoint Compose,
|
||||
порт 5432; не публикуйте порт БД. Поле password хранится только в `pgpass`,
|
||||
доступном UID 65534 с mode 600. Не копируйте `.env` приложения. Этот шаг
|
||||
зависит от секрет-хранилища владельца; универсальной команды получения секрета нет.
|
||||
|
||||
Установите файлы (команды root; каталог контейнера не должен быть volume с
|
||||
непроверенными файлами). `Статьи.csv` расположен рядом с каталогом репозитория:
|
||||
|
||||
```sh
|
||||
docker exec miem_workers-api-1 mkdir -p /opt/miem-import /tmp/miem-import
|
||||
docker cp scripts/import_publications.py miem_workers-api-1:/opt/miem-import/import_publications.py
|
||||
docker cp ../Статьи.csv miem_workers-api-1:/opt/miem-import/articles.csv
|
||||
docker exec miem_workers-api-1 chown -R root:root /opt/miem-import
|
||||
docker exec miem_workers-api-1 chmod 755 /opt/miem-import
|
||||
docker exec miem_workers-api-1 chmod 644 /opt/miem-import/import_publications.py /opt/miem-import/articles.csv
|
||||
docker exec miem_workers-api-1 chown 65534:65534 /opt/miem-import/pg_service.conf /opt/miem-import/pgpass /tmp/miem-import
|
||||
docker exec miem_workers-api-1 chmod 600 /opt/miem-import/pg_service.conf /opt/miem-import/pgpass
|
||||
docker exec miem_workers-api-1 chmod 700 /tmp/miem-import
|
||||
docker exec miem_workers-api-1 sha256sum /opt/miem-import/articles.csv
|
||||
install -m 755 -o root -g root scripts/import-publications-run /usr/local/sbin/miem-publication-import
|
||||
install -m 755 -o root -g root scripts/import-publications-ssh /usr/local/sbin/miem-publication-import-ssh
|
||||
useradd --create-home --shell /bin/sh miem-pub-import
|
||||
install -d -m 700 -o miem-pub-import -g miem-pub-import /home/miem-pub-import/.ssh
|
||||
```
|
||||
|
||||
Проверьте SHA256 CSV по указанному выше значению и script по проверенной копии PR.
|
||||
Секретные файлы должны существовать перед командами chown/chmod. Root-владелец
|
||||
каталога не позволяет UID 65534 заменить программу/CSV; этот UID может писать
|
||||
только отчёт и свои файлы конфигурации. Не предоставляйте SSH-пользователю группу
|
||||
docker или общий sudo. Каталоги и executables выше должны быть root-owned и
|
||||
недоступны для записи этому пользователю. Убедитесь, что абсолютный путь Docker
|
||||
на сервере — `/usr/bin/docker`.
|
||||
|
||||
Создайте `/etc/sudoers.d/miem-publication-import` с mode 440:
|
||||
|
||||
```sudoers
|
||||
miem-pub-import ALL=(root) NOPASSWD: /usr/local/sbin/miem-publication-import dry-run, /usr/local/sbin/miem-publication-import apply
|
||||
```
|
||||
|
||||
```sh
|
||||
visudo -cf /etc/sudoers.d/miem-publication-import
|
||||
```
|
||||
|
||||
В `/home/miem-pub-import/.ssh/authorized_keys` добавьте **только публичный** ключ
|
||||
владельца с ограничениями (замените `<PUBLIC_KEY>` самим публичным ключом):
|
||||
|
||||
```text
|
||||
restrict,command="/usr/local/sbin/miem-publication-import-ssh" <PUBLIC_KEY>
|
||||
```
|
||||
|
||||
```sh
|
||||
chown root:root /home/miem-pub-import/.ssh/authorized_keys
|
||||
chmod 644 /home/miem-pub-import/.ssh/authorized_keys
|
||||
chown root:root /home/miem-pub-import /home/miem-pub-import/.ssh
|
||||
chmod 755 /home/miem-pub-import
|
||||
chmod 755 /home/miem-pub-import/.ssh
|
||||
```
|
||||
|
||||
Root-владение home/.ssh предотвращает замену ограниченного authorized_keys.
|
||||
Убедитесь, что SSH не разрешает этому пользователю другие ключи, пароль,
|
||||
keyboard-interactive, пользовательские environment/rc или общий shell.
|
||||
При необходимости задайте в sshd `Match User miem-pub-import` с
|
||||
`AuthenticationMethods publickey`, `PasswordAuthentication no`,
|
||||
`KbdInteractiveAuthentication no`, `DisableForwarding yes`, `PermitTTY no`,
|
||||
`PermitUserRC no` и указанным ForceCommand. Проверка/reload SSH зависит от
|
||||
конфигурации сервера; перед reload сохраните действующую сессию владельца.
|
||||
|
||||
С клиентской машины параметры хоста/ключа берутся из окружения владельца:
|
||||
|
||||
```sh
|
||||
ssh -i "$IMPORT_SSH_KEY" -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes "miem-pub-import@$PROD_HOST" 'import-publications dry-run'
|
||||
```
|
||||
|
||||
После проверки сводки, выдачи write-grants и отдельного решения владельца:
|
||||
|
||||
```sh
|
||||
ssh -i "$IMPORT_SSH_KEY" -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes "miem-pub-import@$PROD_HOST" 'import-publications apply'
|
||||
ssh -i "$IMPORT_SSH_KEY" -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes "miem-pub-import@$PROD_HOST" 'import-publications dry-run'
|
||||
```
|
||||
|
||||
Повторный dry-run должен показать ноль insert/update. При concurrent changes
|
||||
сводки могут различаться; без production-проверки фактические количества неизвестны.
|
||||
Владелец забирает отчёты без предоставления SCP/general shell временному ключу:
|
||||
|
||||
```sh
|
||||
docker cp miem_workers-api-1:/tmp/miem-import/apply.json ./publication-import-apply.json
|
||||
docker cp miem_workers-api-1:/tmp/miem-import/dry-run.json ./publication-import-dry-run.json
|
||||
```
|
||||
|
||||
## Отзыв доступа
|
||||
|
||||
Владелец прекращает новые SSH-входы: перемещает временный authorized_keys в свой
|
||||
закрытый архив и удаляет только этот sudoers-файл. Не удаляйте данные БД или volumes.
|
||||
|
||||
```sh
|
||||
mv /home/miem-pub-import/.ssh/authorized_keys /var/backups/miem-publication-import/authorized_keys.disabled
|
||||
rm /etc/sudoers.d/miem-publication-import
|
||||
```
|
||||
|
||||
В psql владельцем:
|
||||
|
||||
```sql
|
||||
ALTER ROLE miem_publication_import NOLOGIN;
|
||||
SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE usename = 'miem_publication_import' AND pid <> pg_backend_pid();
|
||||
REVOKE ALL PRIVILEGES ON DATABASE miem_workers FROM miem_publication_import;
|
||||
REVOKE ALL PRIVILEGES ON SCHEMA public FROM miem_publication_import;
|
||||
REVOKE SELECT (id, full_name) ON public.employees FROM miem_publication_import;
|
||||
REVOKE SELECT (id, employee_id, title, year, doi_url, raw_data),
|
||||
INSERT (employee_id, title, year, doi_url, raw_data, source_hash, created_at, updated_at),
|
||||
UPDATE (title, year, doi_url, raw_data, updated_at)
|
||||
ON public.employee_publications FROM miem_publication_import;
|
||||
SELECT format('REVOKE ALL PRIVILEGES ON SEQUENCE %s FROM miem_publication_import', pg_get_serial_sequence('public.employee_publications', 'id')) \gexec
|
||||
DROP ROLE miem_publication_import;
|
||||
```
|
||||
|
||||
После сохранения отчётов и backup удалите только временные секреты подключения:
|
||||
|
||||
```sh
|
||||
docker exec miem_workers-api-1 rm /opt/miem-import/pgpass /opt/miem-import/pg_service.conf
|
||||
```
|
||||
|
||||
Эти действия не используют `DROP OWNED` и не удаляют публикации. SQL роли не
|
||||
является миграцией схемы приложения. Если DROP ROLE не проходит, проверьте
|
||||
оставшиеся grants; не применяйте CASCADE/удаление данных.
|
||||
@@ -0,0 +1,10 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
temporary=$(mktemp)
|
||||
app_dir=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
|
||||
trap 'rm -f -- "$temporary"' EXIT HUP INT TERM
|
||||
|
||||
crontab -l 2>/dev/null | grep -v 'postgres-backup.sh' > "$temporary" || true
|
||||
printf '%s\n' "30 3 * * 0 flock -w 3600 /tmp/miem-postgres-backup.lock $app_dir/infra/postgres-backup.sh || logger -t miem-postgres-backup -- 'backup skipped or failed'" >> "$temporary"
|
||||
crontab "$temporary"
|
||||
@@ -0,0 +1,20 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
umask 077
|
||||
|
||||
app_dir=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
|
||||
backup_dir="$HOME/backups/postgres"
|
||||
backup="$backup_dir/postgres-latest.sql"
|
||||
temporary="$backup.tmp.$$"
|
||||
|
||||
log() { logger -t miem-postgres-backup -- "$*" 2>/dev/null || true; }
|
||||
|
||||
mkdir -p "$backup_dir"
|
||||
trap 'status=$?; rm -f -- "$temporary"; [ "$status" -eq 0 ] || log "backup failed"; exit "$status"' EXIT HUP INT TERM
|
||||
cd "$app_dir"
|
||||
|
||||
docker compose exec -T postgres sh -c 'pg_dumpall -U "$POSTGRES_USER"' > "$temporary"
|
||||
test -s "$temporary"
|
||||
mv "$temporary" "$backup"
|
||||
find "$HOME/backups/miem_workers" -maxdepth 1 -type f -name 'miem_workers-*.sql' -delete 2>/dev/null || true
|
||||
log "created $backup"
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "miem-workers"
|
||||
version = "0.8.2"
|
||||
version = "0.10.4"
|
||||
description = "MIEM employees parser, admin API, and web admin"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/bin/sh
|
||||
# Install root-owned; caller cannot choose container, executable, input or env.
|
||||
set -eu
|
||||
case "${1:-}" in
|
||||
dry-run) mode=dry-run ;;
|
||||
apply) mode=apply ;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
[ "$#" -eq 1 ] || exit 1
|
||||
if [ "$mode" = apply ]; then
|
||||
exec /usr/bin/docker exec --user 65534:65534 \
|
||||
-e PGSERVICEFILE=/opt/miem-import/pg_service.conf \
|
||||
-e PGSERVICE=miem_publication_import \
|
||||
-e PGPASSFILE=/opt/miem-import/pgpass \
|
||||
-e PYTHONNOUSERSITE=1 \
|
||||
miem_workers-api-1 python -I /opt/miem-import/import_publications.py \
|
||||
/opt/miem-import/articles.csv --apply --report /tmp/miem-import/apply.json
|
||||
fi
|
||||
exec /usr/bin/docker exec --user 65534:65534 \
|
||||
-e PGSERVICEFILE=/opt/miem-import/pg_service.conf \
|
||||
-e PGSERVICE=miem_publication_import \
|
||||
-e PGPASSFILE=/opt/miem-import/pgpass \
|
||||
-e PYTHONNOUSERSITE=1 \
|
||||
miem_workers-api-1 python -I /opt/miem-import/import_publications.py \
|
||||
/opt/miem-import/articles.csv --report /tmp/miem-import/dry-run.json
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/bin/sh
|
||||
# Forced SSH command: no arguments, paths, forwarding or shell from the client.
|
||||
set -eu
|
||||
case "${SSH_ORIGINAL_COMMAND:-}" in
|
||||
'import-publications dry-run') exec sudo -n /usr/local/sbin/miem-publication-import dry-run ;;
|
||||
'import-publications apply') exec sudo -n /usr/local/sbin/miem-publication-import apply ;;
|
||||
*) printf '%s\n' 'Allowed: import-publications dry-run | import-publications apply' >&2; exit 1 ;;
|
||||
esac
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Owner-run CSV enrichment; no app startup, .env loading, DDL or deletes."""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import unicodedata
|
||||
from collections import Counter, defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def normalized(value):
|
||||
return " ".join(unicodedata.normalize("NFKC", value or "").casefold().replace("ё", "е").split())
|
||||
|
||||
|
||||
def doi(value):
|
||||
return re.sub(r"^(?:https?://(?:dx\.)?doi\.org/|doi:\s*)", "", (value or "").strip(), flags=re.I).lower()
|
||||
|
||||
|
||||
def read_rows(path):
|
||||
with path.open(encoding="utf-8-sig", newline="") as source:
|
||||
reader = csv.DictReader(source, delimiter=";")
|
||||
required = {"Название публикации", "Год", "DOI", "Авторы НИУ ВШЭ"}
|
||||
if not required <= set(reader.fieldnames or []):
|
||||
raise ValueError("Missing required CSV headers")
|
||||
rows = []
|
||||
for number, row in enumerate(reader, 2):
|
||||
if None in row or any(v is None for v in row.values()):
|
||||
raise ValueError(f"Malformed CSV record {number}")
|
||||
rows.append((number, {k.strip(): v.strip() for k, v in row.items()}))
|
||||
return rows
|
||||
|
||||
|
||||
def plan(rows, employees, publications):
|
||||
names = defaultdict(list)
|
||||
by_employee = defaultdict(list)
|
||||
for employee in employees:
|
||||
if normalized(employee["full_name"]):
|
||||
names[normalized(employee["full_name"])].append(employee["id"])
|
||||
for publication in publications:
|
||||
by_employee[publication["employee_id"]].append(dict(publication))
|
||||
actions, audit, seen = [], [], set()
|
||||
for number, row in rows:
|
||||
title, identifier = row["Название публикации"], doi(row["DOI"])
|
||||
author_names = sorted(set(normalized(a) for a in row["Авторы НИУ ВШЭ"].split(";")) - {""})
|
||||
year = int(row["Год"]) if row["Год"].isdigit() else None
|
||||
if not title or not year or not 1000 <= year <= 9999 or not author_names or (identifier and not re.fullmatch(r"10\.\d{4,9}/\S+", identifier)):
|
||||
audit.append({"row": number, "employee_id": None, "result": "skip", "reason": "invalid_fields"})
|
||||
continue
|
||||
for author in author_names:
|
||||
ids = names.get(author, [])
|
||||
entry = {"row": number, "employee_id": ids[0] if len(ids) == 1 else None}
|
||||
def skip(reason):
|
||||
audit.append({**entry, "result": "skip", "reason": reason})
|
||||
if len(ids) != 1:
|
||||
skip("employee_not_found" if not ids else "ambiguous_employee")
|
||||
continue
|
||||
employee_id = ids[0]
|
||||
candidates = by_employee[employee_id]
|
||||
exact = [p for p in candidates if identifier and doi(p.get("doi_url")) == identifier]
|
||||
title_matches = [p for p in candidates if normalized(p["title"]) == normalized(title) and p.get("year") in (None, year)]
|
||||
matches = {p["id"]: p for p in exact + title_matches}
|
||||
if len(matches) > 1:
|
||||
skip("ambiguous_publication")
|
||||
continue
|
||||
existing = next(iter(matches.values()), None)
|
||||
if existing and identifier and doi(existing.get("doi_url")) and doi(existing["doi_url"]) != identifier:
|
||||
skip("doi_conflict")
|
||||
continue
|
||||
if existing and existing.get("year") and existing["year"] != year:
|
||||
skip("year_conflict")
|
||||
continue
|
||||
key = (employee_id, identifier or (normalized(title), year))
|
||||
duplicate = key in seen
|
||||
seen.add(key)
|
||||
metadata = {k: v for k, v in row.items() if v}
|
||||
raw = existing.get("raw_data") if existing else None
|
||||
if raw is not None and not isinstance(raw, dict):
|
||||
skip("unsupported_raw_data")
|
||||
continue
|
||||
raw = dict(raw or {})
|
||||
old_metadata = raw.get("csv_enrichment", {})
|
||||
if not isinstance(old_metadata, dict):
|
||||
skip("unsupported_csv_metadata")
|
||||
continue
|
||||
conflicts = sorted(k for k, v in metadata.items() if old_metadata.get(k) and old_metadata[k] != v)
|
||||
if conflicts:
|
||||
entry["conflict_fields"] = conflicts
|
||||
merged = dict(old_metadata)
|
||||
for k, v in metadata.items():
|
||||
if not merged.get(k):
|
||||
merged[k] = v
|
||||
raw["csv_enrichment"] = merged
|
||||
values = {"raw_data": raw}
|
||||
if identifier and (not existing or not existing.get("doi_url")):
|
||||
values["doi_url"] = "https://doi.org/" + identifier
|
||||
if not existing or not existing.get("year"):
|
||||
values["year"] = year
|
||||
if not existing or not existing.get("title"):
|
||||
values["title"] = title
|
||||
if existing:
|
||||
values = {k: v for k, v in values.items() if existing.get(k) != v}
|
||||
if not values:
|
||||
skip("metadata_conflict_first_value_kept" if conflicts else "csv_duplicate" if duplicate else "already_present")
|
||||
continue
|
||||
if isinstance(existing["id"], str):
|
||||
actions[int(existing["id"].split("-")[1])]["values"].update(values)
|
||||
existing.update(values)
|
||||
skip("metadata_conflict_first_value_kept" if conflicts else "merged_csv_duplicate")
|
||||
continue
|
||||
action = {"result": "update", "id": existing["id"], "values": values}
|
||||
existing.update(values)
|
||||
else:
|
||||
source_hash = hashlib.sha256(json.dumps(key, ensure_ascii=False, sort_keys=True).encode()).hexdigest()
|
||||
values.update(employee_id=employee_id, source_hash=source_hash)
|
||||
action = {"result": "insert", "values": values}
|
||||
by_employee[employee_id].append({**values, "id": f"planned-{len(actions)}"})
|
||||
actions.append(action)
|
||||
audit.append({**entry, "result": action["result"], "reason": "metadata_conflict_first_value_kept" if conflicts else ""})
|
||||
return actions, audit
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("file", type=Path)
|
||||
parser.add_argument("--apply", action="store_true", help="Commit; default is read-only dry-run")
|
||||
parser.add_argument("--report", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
pending_report = args.report.with_suffix(args.report.suffix + ".pending")
|
||||
if args.file.resolve() in (args.report.resolve(), pending_report.resolve()):
|
||||
raise ValueError("Report must not overwrite input")
|
||||
source_digest = hashlib.sha256(args.file.read_bytes()).hexdigest()
|
||||
rows = read_rows(args.file)
|
||||
# libpq environment/service only; never load application settings or .env.
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg.types.json import Jsonb
|
||||
from psycopg import sql
|
||||
|
||||
# Check report access before any possible commit; pending file is not a success report.
|
||||
with pending_report.open("x", encoding="utf-8"):
|
||||
pass
|
||||
with psycopg.connect("", row_factory=dict_row) as connection:
|
||||
connection.execute("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE" + ("" if args.apply else " READ ONLY"))
|
||||
connection.execute("SET LOCAL search_path = pg_catalog, public")
|
||||
connection.execute("SET LOCAL statement_timeout = '60s'")
|
||||
connection.execute("SET LOCAL lock_timeout = '5s'")
|
||||
if args.apply:
|
||||
locked = connection.execute("SELECT pg_try_advisory_xact_lock(716203841)").fetchone()
|
||||
if not next(iter(locked.values())):
|
||||
raise RuntimeError("Another import is running")
|
||||
employees = connection.execute("SELECT id, full_name FROM public.employees").fetchall()
|
||||
publications = connection.execute("SELECT id, employee_id, title, year, doi_url, raw_data FROM public.employee_publications").fetchall()
|
||||
actions, audit = plan(rows, employees, publications)
|
||||
if args.apply:
|
||||
for action in actions:
|
||||
values = dict(action["values"])
|
||||
if "raw_data" in values:
|
||||
values["raw_data"] = Jsonb(values["raw_data"])
|
||||
columns = list(values)
|
||||
if action["result"] == "insert":
|
||||
query = sql.SQL("INSERT INTO public.employee_publications ({}, created_at, updated_at) VALUES ({}, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)").format(
|
||||
sql.SQL(", ").join(map(sql.Identifier, columns)), sql.SQL(", ").join(sql.Placeholder() for _ in columns))
|
||||
connection.execute(query, list(values.values()))
|
||||
else:
|
||||
query = sql.SQL("UPDATE public.employee_publications SET {}, updated_at = CURRENT_TIMESTAMP WHERE id = %s").format(
|
||||
sql.SQL(", ").join(sql.SQL("{} = %s").format(sql.Identifier(k)) for k in columns))
|
||||
result = connection.execute(query, [*values.values(), action["id"]])
|
||||
if result.rowcount != 1:
|
||||
raise RuntimeError("Publication changed concurrently")
|
||||
if hashlib.sha256(args.file.read_bytes()).hexdigest() != source_digest:
|
||||
raise RuntimeError("Input changed during import")
|
||||
summary = {"mode": "apply" if args.apply else "dry-run", "csv_rows": len(rows),
|
||||
"sha256": source_digest,
|
||||
"counts": {key: sum(item["result"] == key for item in audit) for key in ("insert", "update", "skip")},
|
||||
"metadata_conflicts": sum(bool(item.get("conflict_fields")) for item in audit),
|
||||
"skip_reasons": dict(Counter(item["reason"] for item in audit if item["result"] == "skip"))}
|
||||
pending_report.write_text(json.dumps({"summary": summary, "records": audit}, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
if not args.apply:
|
||||
connection.rollback()
|
||||
pending_report.replace(args.report)
|
||||
print(json.dumps(summary, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as error:
|
||||
# Driver errors may contain connection credentials or personal data.
|
||||
print(f"Import failed ({type(error).__name__}); transaction not partially applied. Check configuration and permissions.")
|
||||
raise SystemExit(1)
|
||||
@@ -67,6 +67,23 @@ def main():
|
||||
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")
|
||||
|
||||
@@ -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;
|
||||
+1
-5
@@ -18,11 +18,7 @@ def test_health_returns_versions():
|
||||
response = TestClient(app).get("/api/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["backend_version"] == "0.8.2"
|
||||
|
||||
|
||||
def test_mcp_endpoint_is_removed():
|
||||
assert TestClient(app).get("/mcp").status_code == 404
|
||||
assert response.json()["backend_version"] == "0.10.4"
|
||||
|
||||
|
||||
def test_api_employees_and_stats_require_admin_session():
|
||||
|
||||
@@ -11,3 +11,12 @@ def test_numeric_crawl_limit_is_parsed():
|
||||
settings = Settings(crawl_limit="25")
|
||||
|
||||
assert settings.crawl_limit == 25
|
||||
|
||||
|
||||
def test_dismissal_safety_settings_have_conservative_defaults(monkeypatch):
|
||||
monkeypatch.delenv("DISMISSAL_CONFIRMATION_RUNS", raising=False)
|
||||
monkeypatch.delenv("MAX_AUTO_DISMISSALS_PER_RUN", raising=False)
|
||||
settings = Settings(_env_file=None)
|
||||
|
||||
assert settings.dismissal_confirmation_runs == 3
|
||||
assert settings.max_auto_dismissals_per_run == 25
|
||||
@@ -0,0 +1,76 @@
|
||||
import csv
|
||||
from datetime import date
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import Employee
|
||||
from app.security import SESSION_COOKIE, sign_session
|
||||
from app.services import council
|
||||
|
||||
|
||||
def test_council_counts_unique_articles_and_only_active_doctors(db_session, monkeypatch, tmp_path):
|
||||
fields = ["Название публикации", "Год", "DOI", "EID (Scopus)", "UT (WoS)",
|
||||
"Авторы НИУ ВШЭ", "Квартиль scopus", "Квартиль wos"]
|
||||
export = tmp_path / "articles.csv"
|
||||
with export.open("w", encoding="utf-8-sig", newline="") as output:
|
||||
writer = csv.DictWriter(output, fieldnames=fields, delimiter=";")
|
||||
writer.writeheader()
|
||||
for number in range(10):
|
||||
writer.writerow({"Название публикации": f"Article {number}", "Год": 2024,
|
||||
"DOI": f"10.1234/{number}", "EID (Scopus)": f"eid-{number}",
|
||||
"Авторы НИУ ВШЭ": "Иванов Иван Иванович; Петров Пётр Петрович",
|
||||
"Квартиль scopus": "Q1"})
|
||||
writer.writerow({"Название публикации": "Article 0", "Год": 2024, "DOI": "10.1234/0",
|
||||
"UT (WoS)": "ut-0", "Авторы НИУ ВШЭ": "Иванов Иван Иванович",
|
||||
"Квартиль wos": "Q2"})
|
||||
monkeypatch.setattr(council, "EXPORT", export)
|
||||
council._articles_by_author.cache_clear()
|
||||
db_session.add_all([
|
||||
Employee(profile_key="doctor", canonical_url="https://example.org/1", full_name="Иванов Иван Иванович",
|
||||
status="active", current_data={"sections": [{"title": "Учёные степени",
|
||||
"items": ["Доктор технических наук"]}]}),
|
||||
Employee(profile_key="candidate", canonical_url="https://example.org/2", full_name="Петров Пётр Петрович",
|
||||
status="active", current_data={"sections": [{"title": "Учёные степени",
|
||||
"items": ["Кандидат технических наук"]}]}),
|
||||
])
|
||||
db_session.commit()
|
||||
result = council.council_candidates(db_session, date(2026, 9, 30))
|
||||
assert result["total"] == 1
|
||||
assert result["candidates"][0]["count"] == 10
|
||||
assert len(result["candidates"][0]["articles"][0]["sources"]) == 2
|
||||
assert council.period(date(2024, 2, 29))[0] == date(2019, 2, 28)
|
||||
settings = Settings(_env_file=None, session_secret="test-session-secret")
|
||||
monkeypatch.setattr("app.admin.council_candidates", lambda db: council.council_candidates(db, date(2026, 9, 30)))
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
app.dependency_overrides[get_settings] = lambda: settings
|
||||
try:
|
||||
client = TestClient(app)
|
||||
client.cookies.set(SESSION_COOKIE, sign_session(settings.admin_username, settings))
|
||||
response = client.get("/admin/council")
|
||||
assert response.status_code == 200
|
||||
assert "Иванов Иван Иванович" in response.text
|
||||
assert "Article 0" in response.text
|
||||
assert "публикаций по выгрузке" in response.text
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
council._articles_by_author.cache_clear()
|
||||
|
||||
|
||||
def test_council_page_requires_admin_and_explains_preliminary_result(db_session, monkeypatch):
|
||||
monkeypatch.setattr(council, "_articles_by_author", lambda: {})
|
||||
settings = Settings(_env_file=None, session_secret="test-session-secret")
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
app.dependency_overrides[get_settings] = lambda: settings
|
||||
try:
|
||||
client = TestClient(app)
|
||||
assert client.get("/admin/council", follow_redirects=False).status_code in (302, 303, 401, 403)
|
||||
client.cookies.set(SESSION_COOKIE, sign_session(settings.admin_username, settings))
|
||||
response = client.get("/admin/council")
|
||||
assert response.status_code == 200
|
||||
assert "Предварительный список" in response.text
|
||||
assert "К1 или RSCI могут отсутствовать" in response.text
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
+117
-2
@@ -1,4 +1,5 @@
|
||||
import gzip
|
||||
import gzip
|
||||
import pytest
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.models import (
|
||||
@@ -434,7 +435,7 @@ def test_upsert_employee_saves_publications_and_reuses_existing_rows(db_session)
|
||||
assert publications[0].authors == [{"id": "1", "title_ru": "Автор"}]
|
||||
|
||||
|
||||
def test_upsert_employee_records_publication_errors_without_failing_employee(monkeypatch, db_session):
|
||||
def test_upsert_employee_records_publication_errors_without_failing_employee(monkeypatch, db_session):
|
||||
run = CrawlRun(source_url="https://miem.hse.ru/persons", status="running")
|
||||
db_session.add(run)
|
||||
db_session.commit()
|
||||
@@ -573,3 +574,117 @@ def _parsed_employee_with_news(profile_id: str) -> dict:
|
||||
}
|
||||
]
|
||||
return parsed
|
||||
|
||||
|
||||
def test_csv_enrichment_survives_repeated_crawl_without_duplicate(db_session):
|
||||
from scripts.import_publications import plan
|
||||
from app.services.crawler import _sync_employee_publications
|
||||
|
||||
run = CrawlRun(source_url="https://miem.hse.ru/persons", status="running")
|
||||
db_session.add(run)
|
||||
db_session.commit()
|
||||
employee, _ = _upsert_employee(db_session, run, _parsed_employee_with_publication("csv-preserved"))
|
||||
db_session.commit()
|
||||
existing = db_session.query(EmployeePublication).filter_by(employee_id=employee.id).one()
|
||||
rows = [(number, {"Название публикации": title, "Год": str(year), "DOI": identifier,
|
||||
"Авторы НИУ ВШЭ": employee.full_name, "Квартиль scopus": "Q1"})
|
||||
for number, title, year, identifier in (
|
||||
(2, existing.title, existing.year, ""),
|
||||
(3, "CSV only", 2024, "10.1234/csv-only"),
|
||||
(4, "Later HSE", 2024, "10.1234/later"))]
|
||||
actions, _ = plan(rows, [{"id": employee.id, "full_name": employee.full_name}],
|
||||
[{field: getattr(existing, field) for field in
|
||||
("id", "employee_id", "title", "year", "doi_url", "raw_data")}])
|
||||
for action in actions:
|
||||
if action["result"] == "insert":
|
||||
db_session.add(EmployeePublication(**action["values"]))
|
||||
else:
|
||||
for field, value in action["values"].items():
|
||||
setattr(existing, field, value)
|
||||
db_session.add(EmployeePublication(employee_id=employee.id, title="Obsolete HSE",
|
||||
source_hash="obsolete", raw_data={"id": "obsolete"}))
|
||||
db_session.commit()
|
||||
original_id = existing.id
|
||||
imported_ids = {p.title: p.id for p in db_session.query(EmployeePublication).all()}
|
||||
|
||||
parsed = _parsed_employee_with_publication("csv-preserved")
|
||||
publication = parsed["sections"][0]["publications"][0]
|
||||
publication.pop("year")
|
||||
publication.pop("doi_url")
|
||||
publication["raw_data"]["fresh"] = True
|
||||
for _ in range(2):
|
||||
_sync_employee_publications(db_session, employee, parsed)
|
||||
db_session.commit()
|
||||
remaining = db_session.query(EmployeePublication).filter_by(employee_id=employee.id).all()
|
||||
assert len(remaining) == 3
|
||||
assert all(p.raw_data["csv_enrichment"]["Квартиль scopus"] == "Q1" for p in remaining)
|
||||
refreshed = db_session.get(EmployeePublication, original_id)
|
||||
assert refreshed.raw_data["fresh"] is True
|
||||
assert refreshed.year == 2023 and refreshed.doi_url == "https://doi.org/10.1/test"
|
||||
assert db_session.get(EmployeePublication, imported_ids["CSV only"]) is not None
|
||||
|
||||
parsed["sections"][0]["publications"].append({
|
||||
"publication_id": "later-id", "title": "Later HSE", "year": 2024,
|
||||
"doi_url": "http://dx.doi.org/10.1234/LATER", "raw_data": {"id": "later-id"}})
|
||||
for _ in range(2):
|
||||
_sync_employee_publications(db_session, employee, parsed)
|
||||
db_session.commit()
|
||||
assert db_session.query(EmployeePublication).filter_by(employee_id=employee.id).count() == 3
|
||||
adopted = db_session.get(EmployeePublication, imported_ids["Later HSE"])
|
||||
assert adopted.publication_id == "later-id"
|
||||
assert adopted.raw_data["csv_enrichment"]["Квартиль scopus"] == "Q1"
|
||||
|
||||
|
||||
def test_ambiguous_csv_match_rolls_back_publication_sync(db_session):
|
||||
from app.services.crawler import _try_sync_employee_publications
|
||||
run = CrawlRun(source_url="https://miem.hse.ru/persons", status="running")
|
||||
db_session.add(run)
|
||||
db_session.commit()
|
||||
employee, _ = _upsert_employee(db_session, run, _parsed_employee("csv-ambiguous"))
|
||||
for index in range(2):
|
||||
db_session.add(EmployeePublication(employee_id=employee.id, title="Same", year=2024,
|
||||
doi_url="https://doi.org/10.1234/same", source_hash=f"csv-{index}",
|
||||
raw_data={"csv_enrichment": {"Год": "2024"}}))
|
||||
db_session.commit()
|
||||
parsed = {"sections": [{"type": "publications", "publications": [
|
||||
{"publication_id": "new", "title": "New"},
|
||||
{"publication_id": "same", "title": "Same", "doi_url": "https://doi.org/10.1234/same", "year": 2024}]}]}
|
||||
_try_sync_employee_publications(db_session, run, employee, parsed)
|
||||
db_session.commit()
|
||||
assert db_session.query(EmployeePublication).filter_by(employee_id=employee.id).count() == 2
|
||||
assert db_session.query(CrawlError).filter_by(crawl_run_id=run.id, error_type="ValueError").count() == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bound_id", [None, "hse-A"])
|
||||
@pytest.mark.parametrize("incoming_ids", [("hse-A", "hse-B"), ("hse-B", "hse-A")])
|
||||
def test_two_hse_ids_cannot_overwrite_one_csv_record(db_session, bound_id, incoming_ids):
|
||||
from app.services.crawler import _try_sync_employee_publications
|
||||
from app.services.admin_data import run_detail_payload
|
||||
run = CrawlRun(source_url="https://miem.hse.ru/persons", status="running")
|
||||
db_session.add(run)
|
||||
db_session.commit()
|
||||
employee, _ = _upsert_employee(db_session, run, _parsed_employee("csv-hse-id-conflict"))
|
||||
original_raw = {"csv_enrichment": {"Год": "2024"}, "original": "keep"}
|
||||
original_authors = [{"title_ru": "Synthetic original author"}]
|
||||
record = EmployeePublication(employee_id=employee.id, title="Same", year=2024,
|
||||
publication_id=bound_id, source_hash="csv-original", raw_data=original_raw,
|
||||
authors=original_authors)
|
||||
db_session.add(record)
|
||||
db_session.commit()
|
||||
original_id = record.id
|
||||
parsed = {"sections": [{"type": "publications", "publications": [
|
||||
{"publication_id": identifier, "title": "Same", "year": 2024,
|
||||
"authors": [{"id": identifier}], "raw_data": {"id": identifier}}
|
||||
for identifier in incoming_ids]}]}
|
||||
_try_sync_employee_publications(db_session, run, employee, parsed)
|
||||
db_session.commit()
|
||||
db_session.expire_all()
|
||||
kept = db_session.get(EmployeePublication, original_id)
|
||||
assert db_session.query(EmployeePublication).filter_by(employee_id=employee.id).count() == 1
|
||||
assert kept.publication_id == bound_id and kept.source_hash == "csv-original"
|
||||
assert kept.raw_data == original_raw and kept.authors == original_authors
|
||||
errors = run_detail_payload(db_session, run)["errors"]
|
||||
assert len(errors) == 1
|
||||
assert errors[0]["error_type"] == "ValueError"
|
||||
assert "Ambiguous HSE IDs" in errors[0]["message"]
|
||||
assert "hse-A" in errors[0]["message"] and "hse-B" in errors[0]["message"]
|
||||
@@ -0,0 +1,233 @@
|
||||
"""Offline checks: synthetic identities only, no settings or database access."""
|
||||
from scripts.import_publications import plan, read_rows
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def row(title="Test", doi="10.1234/test", year="2024", authors="Test Person"):
|
||||
return {"Название публикации": title, "DOI": doi, "Год": year,
|
||||
"Авторы НИУ ВШЭ": authors}
|
||||
|
||||
|
||||
EMPLOYEES = [{"id": 1, "full_name": "Test Person"}]
|
||||
|
||||
|
||||
def test_repeat_and_no_new_employees():
|
||||
rows = [(2, row()), (3, row()), (4, row(authors="Missing Person"))]
|
||||
actions, audit = plan(rows, EMPLOYEES, [])
|
||||
assert len(actions) == 1 and actions[0]["result"] == "insert"
|
||||
assert [a["reason"] for a in audit] == ["", "csv_duplicate", "employee_not_found"]
|
||||
stored = [{"id": 11, **actions[0]["values"]}]
|
||||
repeat, _ = plan(rows, EMPLOYEES, stored)
|
||||
assert repeat == []
|
||||
|
||||
|
||||
def test_ambiguity_and_conflicts():
|
||||
assert not plan([(2, row())], EMPLOYEES * 2, [])[0]
|
||||
stored = [{"id": 1, "employee_id": 1, "title": "Test", "year": 2024,
|
||||
"doi_url": "https://doi.org/10.1234/other", "raw_data": {}}]
|
||||
assert plan([(2, row())], EMPLOYEES, stored)[1][0]["reason"] == "doi_conflict"
|
||||
stored[0]["doi_url"] = "https://doi.org/10.1234/test"
|
||||
assert plan([(2, row(year="2025"))], EMPLOYEES, stored)[1][0]["reason"] == "year_conflict"
|
||||
assert plan([(2, row())], EMPLOYEES, stored * 2)[0] # same id is one identity
|
||||
stored.append({**stored[0], "id": 2})
|
||||
assert plan([(2, row())], EMPLOYEES, stored)[1][0]["reason"] == "ambiguous_publication"
|
||||
|
||||
|
||||
def test_fill_only_preserves_metadata_and_empty_values():
|
||||
stored = [{"id": 1, "employee_id": 1, "title": "Test", "year": 2024,
|
||||
"doi_url": None, "raw_data": {"hse": "keep", "csv_enrichment": {"extra": "keep"}}}]
|
||||
actions, _ = plan([(2, {**row(), "extra": "", "new": "added"})], EMPLOYEES, stored)
|
||||
values = actions[0]["values"]
|
||||
assert set(values) == {"doi_url", "raw_data"}
|
||||
assert values["raw_data"]["hse"] == "keep"
|
||||
assert values["raw_data"]["csv_enrichment"]["extra"] == "keep"
|
||||
assert values["raw_data"]["csv_enrichment"]["new"] == "added"
|
||||
assert "new" not in stored[0]["raw_data"]["csv_enrichment"]
|
||||
|
||||
|
||||
def test_missing_doi_then_doi_does_not_update_synthetic_id():
|
||||
actions, _ = plan([(2, row(doi="")), (3, row())], EMPLOYEES, [])
|
||||
assert len(actions) == 1 and actions[0]["result"] == "insert"
|
||||
assert actions[0]["values"]["doi_url"] == "https://doi.org/10.1234/test"
|
||||
|
||||
|
||||
def test_invalid_rows_and_owner_file():
|
||||
assert not plan([(2, row(year="oops")), (3, row(doi="bad"))], EMPLOYEES, [])[0]
|
||||
path = Path(__file__).resolve().parents[2] / "Статьи.csv"
|
||||
if path.exists():
|
||||
rows = read_rows(path)
|
||||
assert len(rows) == 1797
|
||||
actions, audit = plan(rows, [], [])
|
||||
assert not actions and audit and all(a["result"] == "skip" for a in audit)
|
||||
|
||||
|
||||
def test_cli_read_only_and_atomic_failure(tmp_path, monkeypatch):
|
||||
import sys
|
||||
import psycopg
|
||||
from scripts import import_publications
|
||||
|
||||
source = tmp_path / "input.csv"
|
||||
source.write_text("Название публикации;Год;DOI;Авторы НИУ ВШЭ\nFirst;2024;10.1234/first;Test Person\nSecond;2024;10.1234/second;Test Person\n", encoding="utf-8")
|
||||
|
||||
class Result:
|
||||
rowcount = 1
|
||||
|
||||
def __init__(self, rows):
|
||||
self.rows = rows
|
||||
|
||||
def fetchall(self):
|
||||
return self.rows
|
||||
|
||||
def fetchone(self):
|
||||
return self.rows[0]
|
||||
|
||||
class Connection:
|
||||
def __init__(self, fail=False):
|
||||
self.queries, self.writes = [], []
|
||||
self.fail, self.rolled_back, self.committed = fail, False, False
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, kind, *_):
|
||||
if kind:
|
||||
self.rollback()
|
||||
elif not self.rolled_back:
|
||||
self.committed = True
|
||||
|
||||
def rollback(self):
|
||||
self.rolled_back = True
|
||||
self.writes.clear()
|
||||
|
||||
def execute(self, query, params=None):
|
||||
query = query if isinstance(query, str) else query.as_string()
|
||||
self.queries.append(query)
|
||||
if query.startswith("SELECT id, full_name"):
|
||||
return Result(EMPLOYEES)
|
||||
if query.startswith("SELECT id, employee_id"):
|
||||
return Result([])
|
||||
if "pg_try_advisory" in query:
|
||||
return Result([{"locked": True}])
|
||||
if query.startswith("INSERT"):
|
||||
self.writes.append(params)
|
||||
if self.fail and len(self.writes) == 2:
|
||||
raise RuntimeError("Synthetic second-write failure")
|
||||
return Result([])
|
||||
|
||||
report = tmp_path / "dry.json"
|
||||
dry = Connection()
|
||||
monkeypatch.setattr(psycopg, "connect", lambda *a, **k: dry)
|
||||
monkeypatch.setattr(sys, "argv", ["import_publications", str(source), "--report", str(report)])
|
||||
import_publications.main()
|
||||
assert dry.rolled_back and not dry.committed and not dry.writes
|
||||
assert not any(q.startswith(("INSERT", "UPDATE", "DELETE")) for q in dry.queries)
|
||||
assert "READ ONLY" in dry.queries[0]
|
||||
assert report.exists()
|
||||
|
||||
failed = Connection(fail=True)
|
||||
monkeypatch.setattr(psycopg, "connect", lambda *a, **k: failed)
|
||||
report = tmp_path / "apply.json"
|
||||
monkeypatch.setattr(sys, "argv", ["import_publications", str(source), "--report", str(report), "--apply"])
|
||||
import pytest
|
||||
with pytest.raises(RuntimeError):
|
||||
import_publications.main()
|
||||
assert failed.rolled_back and not failed.committed and not failed.writes
|
||||
assert not report.exists()
|
||||
|
||||
success = Connection()
|
||||
monkeypatch.setattr(psycopg, "connect", lambda *a, **k: success)
|
||||
monkeypatch.setattr(sys, "argv", ["import_publications", str(source), "--report", str(tmp_path / "success.json"), "--apply"])
|
||||
import_publications.main()
|
||||
assert success.committed and len(success.writes) == 2
|
||||
assert not any(q.startswith(("DELETE", "ALTER", "CREATE")) for q in success.queries)
|
||||
|
||||
|
||||
def test_report_cannot_overwrite_source(tmp_path, monkeypatch):
|
||||
import sys
|
||||
import pytest
|
||||
from scripts import import_publications
|
||||
source = tmp_path / "input.csv"
|
||||
source.write_text("unchanged", encoding="utf-8")
|
||||
monkeypatch.setattr(sys, "argv", ["import_publications", str(source), "--report", str(source)])
|
||||
with pytest.raises(ValueError):
|
||||
import_publications.main()
|
||||
assert source.read_text() == "unchanged"
|
||||
|
||||
|
||||
def test_duplicate_rows_fill_late_metadata_and_report_conflicts():
|
||||
rows = [(2, {**row(), "EID (Scopus)": "", "Квартиль scopus": ""}),
|
||||
(3, {**row(), "EID (Scopus)": "new-id", "Квартиль scopus": "Q1"}),
|
||||
(4, {**row(), "EID (Scopus)": "different-id", "Квартиль scopus": "Q2", "UT (WoS)": "late-wos"})]
|
||||
actions, audit = plan(rows, EMPLOYEES, [])
|
||||
assert len(actions) == 1 and actions[0]["result"] == "insert"
|
||||
metadata = actions[0]["values"]["raw_data"]["csv_enrichment"]
|
||||
assert metadata["EID (Scopus)"] == "new-id" and metadata["Квартиль scopus"] == "Q1"
|
||||
assert metadata["UT (WoS)"] == "late-wos"
|
||||
assert audit[1]["reason"] == "merged_csv_duplicate"
|
||||
assert audit[2]["conflict_fields"] == ["EID (Scopus)", "Квартиль scopus"]
|
||||
assert audit[2]["result"] == "skip"
|
||||
repeated, repeated_audit = plan(rows, EMPLOYEES, [{"id": 11, **actions[0]["values"]}])
|
||||
assert repeated == []
|
||||
assert repeated_audit[2]["reason"] == "metadata_conflict_first_value_kept"
|
||||
assert "different-id" not in str(audit)
|
||||
|
||||
|
||||
def test_cli_apply_year_only_preserves_existing_json(tmp_path, monkeypatch):
|
||||
import json
|
||||
import sys
|
||||
import psycopg
|
||||
from scripts import import_publications
|
||||
source = tmp_path / "input.csv"
|
||||
fields = row()
|
||||
source.write_text(";".join(fields) + "\n" + ";".join(fields.values()) + "\n", encoding="utf-8")
|
||||
original_raw = {"hse": "keep", "csv_enrichment": fields.copy()}
|
||||
stored = {"id": 11, "employee_id": 1, "title": "Test", "year": None,
|
||||
"doi_url": "https://doi.org/10.1234/test", "raw_data": original_raw}
|
||||
actions, _ = plan([(2, fields)], EMPLOYEES, [stored])
|
||||
assert actions[0]["values"] == {"year": 2024}
|
||||
|
||||
class Result:
|
||||
rowcount = 1
|
||||
|
||||
def __init__(self, rows):
|
||||
self.rows = rows
|
||||
|
||||
def fetchall(self):
|
||||
return self.rows
|
||||
|
||||
def fetchone(self):
|
||||
return self.rows[0]
|
||||
|
||||
class Connection:
|
||||
committed = False
|
||||
updates = []
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, error, *_):
|
||||
self.committed = error is None
|
||||
|
||||
def execute(self, query, params=None):
|
||||
query = query if isinstance(query, str) else query.as_string()
|
||||
if query.startswith("SELECT id, full_name"):
|
||||
return Result(EMPLOYEES)
|
||||
if query.startswith("SELECT id, employee_id"):
|
||||
return Result([stored])
|
||||
if "pg_try_advisory" in query:
|
||||
return Result([{"locked": True}])
|
||||
if query.startswith("UPDATE"):
|
||||
self.updates.append((query, params))
|
||||
return Result([])
|
||||
|
||||
connection = Connection()
|
||||
report = tmp_path / "report.json"
|
||||
monkeypatch.setattr(psycopg, "connect", lambda *a, **k: connection)
|
||||
monkeypatch.setattr(sys, "argv", ["import_publications", str(source), "--report", str(report), "--apply"])
|
||||
import_publications.main()
|
||||
assert connection.committed and len(connection.updates) == 1
|
||||
query, params = connection.updates[0]
|
||||
assert '"year" = %s' in query and "raw_data" not in query
|
||||
assert params == [2024, 11]
|
||||
assert stored["raw_data"] == original_raw and stored["year"] is None
|
||||
assert json.loads(report.read_text())["summary"]["counts"] == {"insert": 0, "update": 1, "skip": 0}
|
||||
@@ -0,0 +1,280 @@
|
||||
"""Real PostgreSQL checks; opt in only inside tests/postgres/compose.yml."""
|
||||
|
||||
import copy
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from sqlalchemy import create_engine, select, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings
|
||||
from app.db import Base
|
||||
from app.models import CrawlError, CrawlRun, Employee, EmployeePublication
|
||||
from app.services.crawler import _try_sync_employee_publications, run_crawl
|
||||
|
||||
|
||||
ADMIN_URL = "postgresql+psycopg://test_admin:isolated_test_admin@postgres-test:5432/miem_import_test"
|
||||
pytestmark = pytest.mark.skipif(
|
||||
os.environ.get("MIEM_POSTGRES_INTEGRATION") != "1", reason="Requires isolated PostgreSQL Compose project"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_environment(monkeypatch):
|
||||
assert os.environ.get("DATABASE_URL") == ADMIN_URL, "Use the dedicated test Compose project"
|
||||
|
||||
def forbidden_request(*args, **kwargs):
|
||||
raise AssertionError("External HTTP requests are forbidden in PostgreSQL integration tests")
|
||||
|
||||
monkeypatch.setattr(requests.sessions.Session, "request", forbidden_request)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def database(name):
|
||||
assert name in {"miem_import_test", "miem_crawl_test"}
|
||||
engine = create_engine(ADMIN_URL.replace("/miem_import_test", "/" + name))
|
||||
try:
|
||||
with engine.connect() as connection:
|
||||
assert connection.scalar(text("SELECT current_database()")) == name
|
||||
Base.metadata.create_all(engine)
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text("GRANT USAGE ON SCHEMA public TO import_test"))
|
||||
connection.execute(text("GRANT SELECT (id, full_name) ON public.employees TO import_test"))
|
||||
connection.execute(text("GRANT SELECT (id, employee_id, title, year, doi_url, raw_data) ON public.employee_publications TO import_test"))
|
||||
connection.execute(text("GRANT INSERT (employee_id, title, year, doi_url, raw_data, source_hash, created_at, updated_at) ON public.employee_publications TO import_test"))
|
||||
connection.execute(text("GRANT UPDATE (title, year, doi_url, raw_data, updated_at) ON public.employee_publications TO import_test"))
|
||||
# Created from the actual model: not an assumed sequence name.
|
||||
sequence = connection.scalar(text("SELECT pg_get_serial_sequence('public.employee_publications', 'id')"))
|
||||
assert sequence == "public.employee_publications_id_seq"
|
||||
connection.execute(text("GRANT USAGE ON SEQUENCE public.employee_publications_id_seq TO import_test"))
|
||||
permissions = connection.execute(text("""SELECT
|
||||
has_schema_privilege('import_test', 'public', 'CREATE'),
|
||||
has_table_privilege('import_test', 'public.employees', 'INSERT'),
|
||||
has_table_privilege('import_test', 'public.employee_publications', 'DELETE'),
|
||||
has_sequence_privilege('import_test', 'public.employee_publications_id_seq', 'UPDATE')
|
||||
""")).one()
|
||||
assert not any(permissions)
|
||||
with Session(engine) as session:
|
||||
yield session, name
|
||||
finally:
|
||||
# Only the dedicated ephemeral test DB; never DROP DATABASE or roles.
|
||||
Base.metadata.drop_all(engine)
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pg_import_db():
|
||||
with database("miem_import_test") as db:
|
||||
yield db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pg_crawl_db():
|
||||
with database("miem_crawl_test") as db:
|
||||
yield db
|
||||
|
||||
|
||||
def employee(session, name="Synthetic One", key="one"):
|
||||
record = Employee(full_name=name, profile_key="staff:" + key,
|
||||
canonical_url="https://synthetic.invalid/staff/" + key)
|
||||
session.add(record)
|
||||
session.commit()
|
||||
return record
|
||||
|
||||
|
||||
def article(title="Synthetic article", identifier="10.1234/test", year="2024", author="Synthetic One", **metadata):
|
||||
return {"Название публикации": title, "Год": year, "DOI": identifier,
|
||||
"Авторы НИУ ВШЭ": author, **metadata}
|
||||
|
||||
|
||||
def write_csv(path, rows):
|
||||
columns = list(dict.fromkeys(key for row in rows for key in row))
|
||||
with path.open("w", encoding="utf-8-sig", newline="") as output:
|
||||
writer = csv.DictWriter(output, fieldnames=columns, delimiter=";")
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
|
||||
|
||||
def cli(tmp_path, db, rows, *, apply=False, expected_code=0):
|
||||
_, name = db
|
||||
source = tmp_path / "articles.csv"
|
||||
report = tmp_path / "report.json"
|
||||
write_csv(source, rows)
|
||||
command = [sys.executable, str(Path(__file__).resolve().parents[1] / "scripts/import_publications.py"),
|
||||
str(source), "--report", str(report)]
|
||||
if apply:
|
||||
command.append("--apply")
|
||||
# Explicit whitelist: no production env, .env, pg_service or ~/.pgpass.
|
||||
environment = {"PATH": os.environ["PATH"], "LANG": "C.UTF-8", "PGHOST": "postgres-test",
|
||||
"PGPORT": "5432", "PGDATABASE": name, "PGUSER": "import_test",
|
||||
"PGPASSWORD": "isolated_test_import", "PGPASSFILE": "/dev/null",
|
||||
"PGSERVICEFILE": "/dev/null", "PGCONNECT_TIMEOUT": "5"}
|
||||
result = subprocess.run(command, env=environment, cwd=tmp_path, capture_output=True, text=True, timeout=60)
|
||||
assert result.returncode == expected_code, result.stdout + result.stderr
|
||||
assert "Synthetic One" not in result.stdout and "isolated_test_import" not in result.stdout
|
||||
return json.loads(report.read_text()) if report.exists() else None
|
||||
|
||||
|
||||
def snapshot(session):
|
||||
session.expire_all()
|
||||
tables = {table.name: session.execute(select(table).order_by(*table.primary_key.columns)).all()
|
||||
for table in Base.metadata.sorted_tables}
|
||||
sequences = session.execute(text("SELECT sequencename, last_value FROM pg_sequences WHERE schemaname = 'public' ORDER BY sequencename")).all()
|
||||
session.commit()
|
||||
return tables, sequences
|
||||
|
||||
|
||||
def test_postgres_dry_run_changes_neither_rows_nor_sequences(pg_import_db, tmp_path):
|
||||
session, _ = pg_import_db
|
||||
employee(session)
|
||||
before = snapshot(session)
|
||||
report = cli(tmp_path, pg_import_db, [article()])
|
||||
assert report["summary"]["counts"] == {"insert": 1, "update": 0, "skip": 0}
|
||||
assert snapshot(session) == before
|
||||
|
||||
|
||||
def test_postgres_apply_and_reimport(pg_import_db, tmp_path):
|
||||
session, _ = pg_import_db
|
||||
person = employee(session)
|
||||
existing = EmployeePublication(employee_id=person.id, title="Existing", year=2024,
|
||||
source_hash="existing", raw_data={"hse": "keep"})
|
||||
session.add(existing)
|
||||
session.commit()
|
||||
rows = [article(title="Existing"), article(title="New", identifier="10.1234/new"),
|
||||
article(title="Unknown author", identifier="10.1234/unknown", author="Synthetic Missing")]
|
||||
report = cli(tmp_path, pg_import_db, rows, apply=True)
|
||||
assert report["summary"]["counts"] == {"insert": 1, "update": 1, "skip": 1}
|
||||
session.expire_all()
|
||||
assert session.query(Employee).count() == 1
|
||||
assert session.query(EmployeePublication).count() == 2
|
||||
assert existing.doi_url == "https://doi.org/10.1234/test" and existing.raw_data["hse"] == "keep"
|
||||
before = snapshot(session)
|
||||
repeated = cli(tmp_path, pg_import_db, rows, apply=True)
|
||||
assert repeated["summary"]["counts"] == {"insert": 0, "update": 0, "skip": 3}
|
||||
assert snapshot(session) == before
|
||||
|
||||
|
||||
def test_postgres_apply_failure_rolls_back_update_and_insert(pg_import_db, tmp_path):
|
||||
session, _ = pg_import_db
|
||||
person = employee(session)
|
||||
existing = EmployeePublication(employee_id=person.id, title="Existing", year=2024,
|
||||
source_hash="existing", raw_data={"hse": "keep"})
|
||||
session.add(existing)
|
||||
session.commit()
|
||||
# Test-only constraint forces a real server error after update + successful INSERT.
|
||||
session.execute(text("ALTER TABLE public.employee_publications ADD CONSTRAINT test_failure CHECK (title <> 'Reject')"))
|
||||
session.commit()
|
||||
before, _ = snapshot(session)
|
||||
rows = [article(title="Existing"), article(title="Accepted", identifier="10.1234/accepted"),
|
||||
article(title="Reject", identifier="10.1234/reject")]
|
||||
assert cli(tmp_path, pg_import_db, rows, apply=True, expected_code=1) is None
|
||||
after, _ = snapshot(session)
|
||||
assert after == before # nextval may advance on rollback; rows must not change.
|
||||
assert (tmp_path / "report.json.pending").exists()
|
||||
|
||||
|
||||
def test_postgres_year_only_update_preserves_json(pg_import_db, tmp_path):
|
||||
session, _ = pg_import_db
|
||||
person = employee(session)
|
||||
row = article()
|
||||
raw = {"hse": {"keep": True}, "csv_enrichment": row.copy()}
|
||||
record = EmployeePublication(employee_id=person.id, title=row["Название публикации"],
|
||||
year=None, doi_url="https://doi.org/10.1234/test",
|
||||
source_hash="existing", raw_data=raw)
|
||||
session.add(record)
|
||||
session.commit()
|
||||
report = cli(tmp_path, pg_import_db, [row], apply=True)
|
||||
session.expire_all()
|
||||
assert report["summary"]["counts"]["update"] == 1
|
||||
assert record.year == 2024 and record.raw_data == raw
|
||||
|
||||
|
||||
def test_postgres_late_csv_metadata_and_conflicts(pg_import_db, tmp_path):
|
||||
session, _ = pg_import_db
|
||||
employee(session)
|
||||
rows = [article(), article(**{"EID (Scopus)": "late-id", "Квартиль scopus": "Q1"}),
|
||||
article(**{"EID (Scopus)": "conflict-id", "UT (WoS)": "late-wos"})]
|
||||
report = cli(tmp_path, pg_import_db, rows, apply=True)
|
||||
session.expire_all()
|
||||
assert session.query(EmployeePublication).count() == 1
|
||||
metadata = session.query(EmployeePublication).one().raw_data["csv_enrichment"]
|
||||
assert metadata["EID (Scopus)"] == "late-id" and metadata["Квартиль scopus"] == "Q1"
|
||||
assert metadata["UT (WoS)"] == "late-wos"
|
||||
assert report["summary"]["metadata_conflicts"] == 1
|
||||
assert report["records"][2]["conflict_fields"] == ["EID (Scopus)"]
|
||||
before = snapshot(session)
|
||||
repeated = cli(tmp_path, pg_import_db, rows, apply=True)
|
||||
assert repeated["summary"]["counts"]["insert"] == repeated["summary"]["counts"]["update"] == 0
|
||||
assert snapshot(session) == before
|
||||
|
||||
|
||||
def test_postgres_crawler_preserves_import_and_adopts_hse_id(pg_crawl_db, tmp_path):
|
||||
session, _ = pg_crawl_db
|
||||
person = employee(session)
|
||||
cli(tmp_path, pg_crawl_db, [article(), article(title="CSV only", identifier="10.1234/only")], apply=True)
|
||||
session.expire_all()
|
||||
ids = {p.title: p.id for p in session.query(EmployeePublication).all()}
|
||||
run = CrawlRun(source_url="https://synthetic.invalid/persons", status="running")
|
||||
session.add(run)
|
||||
session.commit()
|
||||
parsed = {"sections": [{"type": "publications", "publications": [
|
||||
{"publication_id": "hse-one", "title": "Synthetic article", "year": 2024,
|
||||
"doi_url": "https://doi.org/10.1234/test", "raw_data": {"id": "hse-one"}}]}]}
|
||||
for _ in range(2):
|
||||
_try_sync_employee_publications(session, run, person, copy.deepcopy(parsed))
|
||||
session.commit()
|
||||
session.expire_all()
|
||||
assert session.query(EmployeePublication).count() == 2
|
||||
kept = session.get(EmployeePublication, ids["Synthetic article"])
|
||||
assert kept.publication_id == "hse-one" and "csv_enrichment" in kept.raw_data
|
||||
assert session.get(EmployeePublication, ids["CSV only"]).raw_data["csv_enrichment"]
|
||||
assert session.query(CrawlError).count() == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bound_id", [None, "hse-A"])
|
||||
@pytest.mark.parametrize("incoming_ids", [("hse-A", "hse-B"), ("hse-B", "hse-A")])
|
||||
def test_postgres_hse_id_conflict_does_not_block_other_employee(pg_crawl_db, tmp_path, monkeypatch, bound_id, incoming_ids):
|
||||
session, _ = pg_crawl_db
|
||||
first = employee(session)
|
||||
second = employee(session, "Synthetic Two", "two")
|
||||
cli(tmp_path, pg_crawl_db, [article(identifier="")], apply=True)
|
||||
session.expire_all()
|
||||
original = session.query(EmployeePublication).filter_by(employee_id=first.id).one()
|
||||
original.publication_id = bound_id
|
||||
original.authors = [{"id": "original-author"}]
|
||||
session.commit()
|
||||
fields = ("publication_id", "source_hash", "raw_data", "authors", "updated_at")
|
||||
before = {field: copy.deepcopy(getattr(original, field)) for field in fields}
|
||||
session.commit()
|
||||
|
||||
def profile(person, publications):
|
||||
return {"source_url": person.canonical_url, "profile_type": "staff",
|
||||
"profile_id": person.profile_key.split(":")[1], "full_name": person.full_name,
|
||||
"parser_version": "integration-test", "tabs": [],
|
||||
"sections": [{"type": "publications", "publications": publications}]}
|
||||
|
||||
profiles = {
|
||||
first.canonical_url: profile(first, [{"publication_id": key, "title": "Synthetic article", "year": 2024,
|
||||
"authors": [{"id": key}], "raw_data": {"id": key}} for key in incoming_ids]),
|
||||
second.canonical_url: profile(second, [{"publication_id": "second-article", "title": "Other employee article",
|
||||
"year": 2024, "raw_data": {"id": "second-article"}}]),
|
||||
}
|
||||
monkeypatch.setattr("app.services.crawler.collect_profile_links", lambda *a, **k: list(profiles))
|
||||
monkeypatch.setattr("app.services.crawler.parse_person_profile", lambda _, url, *a, **k: copy.deepcopy(profiles[url]))
|
||||
settings = Settings(_env_file=None, database_url=ADMIN_URL.replace("miem_import_test", "miem_crawl_test"),
|
||||
source_url="https://synthetic.invalid/persons", request_delay_seconds=0)
|
||||
run = run_crawl(session, settings)
|
||||
session.expire_all()
|
||||
assert run.status == "completed" and run.parsed_count == 2
|
||||
kept = session.query(EmployeePublication).filter_by(employee_id=first.id).one()
|
||||
assert {field: getattr(kept, field) for field in fields} == before
|
||||
assert session.query(EmployeePublication).filter_by(employee_id=second.id).one().publication_id == "second-article"
|
||||
error = session.query(CrawlError).filter_by(crawl_run_id=run.id).one()
|
||||
assert "Ambiguous HSE IDs" in error.message
|
||||
Reference in new issue
Block a user